mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-03 20:40:00 -05:00
Merge 4ef3e55230 into 0263262ef4
This commit is contained in:
commit
c805d03f60
8 changed files with 244 additions and 27 deletions
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue