This commit is contained in:
Scott Bishel 2026-02-04 03:04:14 +02:00 committed by GitHub
commit c805d03f60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 244 additions and 27 deletions

View file

@ -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
});
});
});

View file

@ -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<Props> = ({
}
try {
return new Intl.DateTimeFormat(intl.locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(dateValue);
return formatDateForDisplay(dateValue, intl.locale);
} catch {
return '';
}

View file

@ -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<Props> = ({
}) => {
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);

View file

@ -31,7 +31,7 @@ exports[`components/datetime_input/DateTimeInput should match snapshot 1`] = `
<span
class="date-time-input__value"
>
6/8/2025
Jun 8, 2025
</span>
</div>
</div>

View file

@ -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<typeof timezoneUtils.getCurrentMomentForTimezone>;
const mockIsBeforeTime = timezoneUtils.isBeforeTime as jest.MockedFunction<typeof timezoneUtils.isBeforeTime>;
@ -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(<DateTimeInput {...baseProps}/>);
// 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(<DateTimeInput {...baseProps}/>);
// 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(<DateTimeInput {...baseProps}/>);
// 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(<DateTimeInput {...props}/>);
// 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();
});
});
});

View file

@ -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<Props> = ({
allowPastDates = false,
}: Props) => {
const locale = useSelector(getCurrentLocale);
const isMilitaryTime = useSelector(isUseMilitaryTime);
const [timeOptions, setTimeOptions] = useState<Date[]>([]);
const [isPopperOpen, setIsPopperOpen] = useState(false);
const [isTimeMenuOpen, setIsTimeMenuOpen] = useState(false);
@ -118,7 +120,7 @@ const DateTimeInputContainer: React.FC<Props> = ({
};
}, [handleKeyDown]);
const setTimeAndOptions = () => {
useEffect(() => {
const currentTime = getCurrentMomentForTimezone(timezone);
let startTime = moment(time).startOf('day');
@ -129,9 +131,7 @@ const DateTimeInputContainer: React.FC<Props> = ({
}
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<Props> = ({
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<Props> = ({
<Timestamp
useRelative={false}
useDate={false}
useTime={isMilitaryTime ? {hour: 'numeric', minute: '2-digit', hourCycle: 'h23'} : {hour: 'numeric', minute: '2-digit', hour12: true}}
value={time.toString()}
/>
</span>
@ -234,7 +240,7 @@ const DateTimeInputContainer: React.FC<Props> = ({
>
{timeOptions.map((option, index) => (
<Menu.Item
key={index}
key={option.getTime()}
id={`time_option_${index}`}
data-testid={`time_option_${index}`}
labels={
@ -242,6 +248,7 @@ const DateTimeInputContainer: React.FC<Props> = ({
<Timestamp
useRelative={false}
useDate={false}
useTime={isMilitaryTime ? {hour: 'numeric', minute: '2-digit', hourCycle: 'h23'} : {hour: 'numeric', minute: '2-digit', hour12: true}}
value={option}
/>
</span>

View file

@ -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}$/);
});
});
});

View file

@ -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"