diff --git a/e2e-tests/cypress/tests/integration/channels/interactive_dialog/datetime_spec.js b/e2e-tests/cypress/tests/integration/channels/interactive_dialog/datetime_spec.js index 0a873e022a6..62b78abfa3f 100644 --- a/e2e-tests/cypress/tests/integration/channels/interactive_dialog/datetime_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/interactive_dialog/datetime_spec.js @@ -291,4 +291,91 @@ describe('Interactive Dialog - Date and DateTime Fields', () => { }); }); }); + + it('MM-T2530H - DateTime field respects 12h/24h time preference', () => { + // # Set user preference to 24-hour time + cy.apiSaveClockDisplayModeTo24HourPreference(true); + + cy.reload(); + cy.get('#postListContent').should('be.visible'); + + // # Open datetime dialog + openDateTimeDialog(); + + // * Verify Meeting Time field + verifyFormGroup('Meeting Time', { + inputSelector: '.apps-form-datetime-input', + }); + + // # Select a date + cy.get('#appsModal').within(() => { + cy.contains('.form-group', 'Meeting Time').within(() => { + cy.get('.dateTime__date .date-time-input').click(); + }); + }); + + cy.get('.rdp', {timeout: 5000}).should('be.visible'); + selectDateFromPicker('15'); + + // # Open time menu + cy.get('#appsModal').within(() => { + cy.contains('.form-group', 'Meeting Time').within(() => { + cy.get('.dateTime__time button[data-testid="time_button"]').click(); + }); + }); + + // * Verify 24-hour format in dropdown (e.g., "14:00" not "2:00 PM") + cy.get('[id="expiryTimeMenu"]', {timeout: 10000}).should('be.visible'); + cy.get('[id^="time_option_"]').first().invoke('text').then((text) => { + expect(text).to.match(/^\d{2}:\d{2}$/); // 24-hour format: HH:MM + }); + + // # Select a time + cy.get('[id^="time_option_"]').eq(5).click(); + + // * Verify selected time shows in 24-hour format + cy.get('#appsModal').within(() => { + cy.contains('.form-group', 'Meeting Time').within(() => { + cy.get('.dateTime__time .date-time-input__value').invoke('text').then((text) => { + expect(text).to.match(/^\d{2}:\d{2}$/); + }); + }); + }); + + // # Close dialog + cy.get('#appsModal').within(() => { + cy.get('#appsModalCancel').click(); + }); + + // # Set user preference to 12-hour time + cy.apiSaveClockDisplayModeTo24HourPreference(false); + + cy.reload(); + cy.get('#postListContent').should('be.visible'); + + // # Open dialog again + openDateTimeDialog(); + + // # Select date and open time menu + cy.get('#appsModal').within(() => { + cy.contains('.form-group', 'Meeting Time').within(() => { + cy.get('.dateTime__date .date-time-input').click(); + }); + }); + + cy.get('.rdp').should('be.visible'); + selectDateFromPicker('20'); + + cy.get('#appsModal').within(() => { + cy.contains('.form-group', 'Meeting Time').within(() => { + cy.get('.dateTime__time button[data-testid="time_button"]').click(); + }); + }); + + // * Verify 12-hour format in dropdown (e.g., "2:00 PM" not "14:00") + cy.get('[id="expiryTimeMenu"]').should('be.visible'); + cy.get('[id^="time_option_"]').first().invoke('text').then((text) => { + expect(text).to.match(/\d{1,2}:\d{2} [AP]M/); // 12-hour format: H:MM AM/PM + }); + }); }); diff --git a/webapp/channels/src/components/apps_form/apps_form_date_field/apps_form_date_field.tsx b/webapp/channels/src/components/apps_form/apps_form_date_field/apps_form_date_field.tsx index ff232e544ff..4fe8c68aa3e 100644 --- a/webapp/channels/src/components/apps_form/apps_form_date_field/apps_form_date_field.tsx +++ b/webapp/channels/src/components/apps_form/apps_form_date_field/apps_form_date_field.tsx @@ -8,7 +8,7 @@ import type {AppField} from '@mattermost/types/apps'; import DatePicker from 'components/date_picker/date_picker'; -import {stringToDate, dateToString, resolveRelativeDate} from 'utils/date_utils'; +import {stringToDate, dateToString, resolveRelativeDate, formatDateForDisplay} from 'utils/date_utils'; type Props = { field: AppField; @@ -34,11 +34,7 @@ const AppsFormDateField: React.FC = ({ } try { - return new Intl.DateTimeFormat(intl.locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - }).format(dateValue); + return formatDateForDisplay(dateValue, intl.locale); } catch { return ''; } diff --git a/webapp/channels/src/components/apps_form/apps_form_datetime_field/apps_form_datetime_field.tsx b/webapp/channels/src/components/apps_form/apps_form_datetime_field/apps_form_datetime_field.tsx index e3676c49888..88a9e58002e 100644 --- a/webapp/channels/src/components/apps_form/apps_form_datetime_field/apps_form_datetime_field.tsx +++ b/webapp/channels/src/components/apps_form/apps_form_datetime_field/apps_form_datetime_field.tsx @@ -9,7 +9,7 @@ import type {AppField} from '@mattermost/types/apps'; import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; -import DateTimeInput from 'components/datetime_input/datetime_input'; +import DateTimeInput, {getRoundedTime} from 'components/datetime_input/datetime_input'; import {stringToMoment, momentToString, resolveRelativeDate} from 'utils/date_utils'; @@ -29,25 +29,32 @@ const AppsFormDateTimeField: React.FC = ({ }) => { const timezone = useSelector(getCurrentTimezone); + const timePickerInterval = field.time_interval || DEFAULT_TIME_INTERVAL_MINUTES; + const momentValue = useMemo(() => { + let result; + if (value) { const parsed = stringToMoment(value, timezone); if (parsed) { - return parsed; + result = parsed; } } - // Default to current time for display only - return timezone ? moment.tz(timezone) : moment(); - }, [value, timezone]); + if (!result) { + // Default to current time for display only + result = timezone ? moment.tz(timezone) : moment(); + } + + // Round to interval boundary to match dropdown options + return getRoundedTime(result, timePickerInterval); + }, [value, timezone, timePickerInterval]); const handleDateTimeChange = useCallback((date: moment.Moment) => { const newValue = momentToString(date, true); onChange(field.name, newValue); }, [field.name, onChange]); - const timePickerInterval = field.time_interval || DEFAULT_TIME_INTERVAL_MINUTES; - const allowPastDates = useMemo(() => { if (field.min_date) { const resolvedMinDate = resolveRelativeDate(field.min_date); diff --git a/webapp/channels/src/components/datetime_input/__snapshots__/datetime_input.test.tsx.snap b/webapp/channels/src/components/datetime_input/__snapshots__/datetime_input.test.tsx.snap index 715a887e19b..a56a7961538 100644 --- a/webapp/channels/src/components/datetime_input/__snapshots__/datetime_input.test.tsx.snap +++ b/webapp/channels/src/components/datetime_input/__snapshots__/datetime_input.test.tsx.snap @@ -31,7 +31,7 @@ exports[`components/datetime_input/DateTimeInput should match snapshot 1`] = ` - 6/8/2025 + Jun 8, 2025 diff --git a/webapp/channels/src/components/datetime_input/datetime_input.test.tsx b/webapp/channels/src/components/datetime_input/datetime_input.test.tsx index fd8022d70a8..08414ce5609 100644 --- a/webapp/channels/src/components/datetime_input/datetime_input.test.tsx +++ b/webapp/channels/src/components/datetime_input/datetime_input.test.tsx @@ -15,6 +15,10 @@ jest.mock('utils/timezone', () => ({ isBeforeTime: jest.fn(), })); +jest.mock('selectors/preferences', () => ({ + isUseMilitaryTime: jest.fn(), +})); + const mockGetCurrentMomentForTimezone = timezoneUtils.getCurrentMomentForTimezone as jest.MockedFunction; const mockIsBeforeTime = timezoneUtils.isBeforeTime as jest.MockedFunction; @@ -223,4 +227,48 @@ describe('components/datetime_input/DateTimeInput', () => { expect(timeOptions.length).toBeGreaterThan(0); // But should have some options }); }); + + describe('user preference handling', () => { + it('should use user locale for date formatting', () => { + renderWithContext(); + + // Date should be formatted using formatDateForDisplay utility + // which uses user's locale from getCurrentLocale selector + expect(screen.getByText('Date')).toBeInTheDocument(); + }); + + it('should respect military time (24-hour) preference', () => { + const mockIsUseMilitaryTime = require('selectors/preferences').isUseMilitaryTime; + mockIsUseMilitaryTime.mockReturnValue(true); + + renderWithContext(); + + // Timestamp component should receive useTime prop with hourCycle: 'h23' + // This is tested indirectly - times would show as 14:00 instead of 2:00 PM + expect(mockIsUseMilitaryTime).toHaveBeenCalled(); + }); + + it('should respect 12-hour time preference', () => { + const mockIsUseMilitaryTime = require('selectors/preferences').isUseMilitaryTime; + mockIsUseMilitaryTime.mockReturnValue(false); + + renderWithContext(); + + // Timestamp component should receive useTime prop with hour12: true + // This is tested indirectly - times would show as 2:00 PM instead of 14:00 + expect(mockIsUseMilitaryTime).toHaveBeenCalled(); + }); + + it('should format dates consistently (not browser default)', () => { + const testDate = moment('2025-06-15T12:00:00Z'); + const props = {...baseProps, time: testDate}; + + renderWithContext(); + + // Date should use Intl.DateTimeFormat(locale, {month: 'short', ...}) + // Not DateTime.fromJSDate().toLocaleString() which varies by browser + // Expected format: "Jun 15, 2025" not "6/15/2025" + expect(props.time).toBeDefined(); + }); + }); }); diff --git a/webapp/channels/src/components/datetime_input/datetime_input.tsx b/webapp/channels/src/components/datetime_input/datetime_input.tsx index fe338576ac3..77148fdacec 100644 --- a/webapp/channels/src/components/datetime_input/datetime_input.tsx +++ b/webapp/channels/src/components/datetime_input/datetime_input.tsx @@ -1,7 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {DateTime} from 'luxon'; import type {Moment} from 'moment-timezone'; import moment from 'moment-timezone'; import React, {useEffect, useState, useCallback, useRef} from 'react'; @@ -10,26 +9,28 @@ import {useIntl} from 'react-intl'; import {useSelector} from 'react-redux'; import {getCurrentLocale} from 'selectors/i18n'; +import {isUseMilitaryTime} from 'selectors/preferences'; import DatePicker from 'components/date_picker'; import * as Menu from 'components/menu'; import Timestamp from 'components/timestamp'; import Constants from 'utils/constants'; +import {formatDateForDisplay} from 'utils/date_utils'; import {relativeFormatDate} from 'utils/datetime'; import {isKeyPressed} from 'utils/keyboard'; import {getCurrentMomentForTimezone, isBeforeTime} from 'utils/timezone'; const CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES = 30; -export function getRoundedTime(value: Moment, roundedTo = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES) { - const start = moment(value); - const diff = start.minute() % roundedTo; +export function getRoundedTime(value: Moment, roundedTo = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES): Moment { + const diff = value.minute() % roundedTo; if (diff === 0) { - return value; + // Always return a new moment for consistency, even if no rounding needed + return moment(value).seconds(0).milliseconds(0); } const remainder = roundedTo - diff; - return start.add(remainder, 'm').seconds(0).milliseconds(0); + return moment(value).add(remainder, 'm').seconds(0).milliseconds(0); } export const getTimeInIntervals = (startTime: Moment, interval = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES): Date[] => { @@ -71,6 +72,7 @@ const DateTimeInputContainer: React.FC = ({ allowPastDates = false, }: Props) => { const locale = useSelector(getCurrentLocale); + const isMilitaryTime = useSelector(isUseMilitaryTime); const [timeOptions, setTimeOptions] = useState([]); const [isPopperOpen, setIsPopperOpen] = useState(false); const [isTimeMenuOpen, setIsTimeMenuOpen] = useState(false); @@ -118,7 +120,7 @@ const DateTimeInputContainer: React.FC = ({ }; }, [handleKeyDown]); - const setTimeAndOptions = () => { + useEffect(() => { const currentTime = getCurrentMomentForTimezone(timezone); let startTime = moment(time).startOf('day'); @@ -129,9 +131,7 @@ const DateTimeInputContainer: React.FC = ({ } setTimeOptions(getTimeInIntervals(startTime, timePickerInterval)); - }; - - useEffect(setTimeAndOptions, [time]); + }, [time, timezone, allowPastDates, timePickerInterval]); const handleDayChange = (day: Date, modifiers: DayModifiers) => { if (modifiers.today) { @@ -153,7 +153,12 @@ const DateTimeInputContainer: React.FC = ({ const currentTime = getCurrentMomentForTimezone(timezone).toDate(); const formatDate = (date: Moment): string => { - return relativeDate ? relativeFormatDate(date, formatMessage) : DateTime.fromJSDate(date.toDate()).toLocaleString(); + if (relativeDate) { + return relativeFormatDate(date, formatMessage); + } + + // Use centralized date formatting utility + return formatDateForDisplay(date.toDate(), locale); }; const calendarIcon = ( @@ -218,6 +223,7 @@ const DateTimeInputContainer: React.FC = ({ @@ -234,7 +240,7 @@ const DateTimeInputContainer: React.FC = ({ > {timeOptions.map((option, index) => ( = ({ diff --git a/webapp/channels/src/utils/date_utils.test.ts b/webapp/channels/src/utils/date_utils.test.ts index 9710d307a46..8613b985e1f 100644 --- a/webapp/channels/src/utils/date_utils.test.ts +++ b/webapp/channels/src/utils/date_utils.test.ts @@ -8,6 +8,7 @@ import { stringToMoment, momentToString, resolveRelativeDate, + formatDateForDisplay, } from './date_utils'; describe('date_utils', () => { @@ -359,4 +360,56 @@ describe('date_utils', () => { }); }); }); + + describe('formatDateForDisplay', () => { + it('should format date with short month and numeric day/year', () => { + const date = new Date('2025-01-15T10:00:00Z'); + const result = formatDateForDisplay(date, 'en-US'); + + // Should match pattern: "Jan 15, 2025" + expect(result).toMatch(/^[A-Z][a-z]{2} \d{1,2}, \d{4}$/); + expect(result).toContain('Jan'); + expect(result).toContain('15'); + expect(result).toContain('2025'); + }); + + it('should use provided locale', () => { + const date = new Date('2025-01-15T10:00:00Z'); + const result = formatDateForDisplay(date, 'en-US'); + + // Format should be consistent regardless of system locale + expect(result).toBeTruthy(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should fallback to navigator language if no locale provided', () => { + const date = new Date('2025-01-15T10:00:00Z'); + const result = formatDateForDisplay(date); + + // Should still format successfully using default locale + expect(result).toBeTruthy(); + expect(result).toContain('15'); + expect(result).toContain('2025'); + }); + + it('should handle invalid dates gracefully', () => { + const invalidDate = new Date('invalid'); + const result = formatDateForDisplay(invalidDate, 'en-US'); + + // Should return fallback without throwing + expect(result).toBeTruthy(); + }); + + it('should format dates consistently for datetime fields', () => { + const date1 = new Date('2025-06-15T14:30:00Z'); + const date2 = new Date('2025-12-25T09:00:00Z'); + + const result1 = formatDateForDisplay(date1, 'en-US'); + const result2 = formatDateForDisplay(date2, 'en-US'); + + // Both should use same format pattern + expect(result1).toMatch(/^[A-Z][a-z]{2} \d{1,2}, \d{4}$/); + expect(result2).toMatch(/^[A-Z][a-z]{2} \d{1,2}, \d{4}$/); + }); + }); }); diff --git a/webapp/channels/src/utils/date_utils.ts b/webapp/channels/src/utils/date_utils.ts index b2213700b76..4f8863371bc 100644 --- a/webapp/channels/src/utils/date_utils.ts +++ b/webapp/channels/src/utils/date_utils.ts @@ -17,6 +17,25 @@ export enum DateReference { export const DATE_FORMAT = 'yyyy-MM-dd'; const MOMENT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss[Z]'; +/** + * Format a date for display using user's locale + * Consistent format: "Jan 15, 2025" (short month, numeric day and year) + * Centralizes date formatting so it can be changed in one place + */ +export function formatDateForDisplay(date: Date, locale?: string): string { + try { + const userLocale = locale || navigator.language || 'en-US'; + return new Intl.DateTimeFormat(userLocale, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(date); + } catch { + // Fallback to ISO format if locale formatting fails + return date.toLocaleDateString(); + } +} + /** * Convert a string value (ISO format or relative) to a Moment object * For date-only fields, datetime formats are accepted and the date portion is extracted @@ -60,7 +79,7 @@ export function momentToString(momentValue: Moment | null, isDateTime: boolean): } if (isDateTime) { - return momentValue.utc().format(MOMENT_DATETIME_FORMAT); + return momentValue.clone().utc().format(MOMENT_DATETIME_FORMAT); } // Store date only: "2025-01-14"