This commit is contained in:
Scott Bishel 2026-02-04 03:04:17 +02:00 committed by GitHub
commit 1438debc71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1020 additions and 114 deletions

View file

@ -291,4 +291,250 @@ 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
});
});
it('MM-T2530O - Manual time entry (basic functionality)', () => {
// # Open timezone-manual dialog via webhook
openDateTimeDialog('timezone-manual');
verifyModalTitle('Timezone & Manual Entry Demo');
// * Verify local manual entry field exists
verifyFormGroup('Your Local Time (Manual Entry)', {
helpText: 'Type any time',
});
// # Type a time in manual entry field
cy.get('#appsModal').within(() => {
cy.contains('.form-group', 'Your Local Time (Manual Entry)').within(() => {
cy.get('input#time_input').should('be.visible').type('3:45pm').blur();
});
});
// * Verify time is accepted (no error state)
cy.contains('.form-group', 'Your Local Time (Manual Entry)').within(() => {
cy.get('input#time_input').should('not.have.class', 'error');
cy.get('input#time_input').should('have.value', '3:45 PM');
});
// # Submit form
cy.get('#appsModal').within(() => {
cy.get('#appsModalSubmit').click();
});
// * Verify submission success
cy.get('#appsModal', {timeout: 10000}).should('not.exist');
});
it('MM-T2530P - Manual time entry (multiple formats)', () => {
openDateTimeDialog('timezone-manual');
const testFormats = [
{input: '12a', expected12h: '12:00 AM'},
{input: '14:30', expected12h: '2:30 PM'},
{input: '9pm', expected12h: '9:00 PM'},
];
testFormats.forEach(({input, expected12h}) => {
cy.contains('.form-group', 'Your Local Time (Manual Entry)').within(() => {
cy.get('input#time_input').clear().type(input).blur();
// Wait for formatting to apply
cy.wait(100);
// Verify time is formatted correctly (assumes 12h preference for test consistency)
cy.get('input#time_input').invoke('val').should('equal', expected12h);
});
});
});
it('MM-T2530Q - Manual time entry (invalid format)', () => {
openDateTimeDialog('timezone-manual');
// # Type invalid time
cy.contains('.form-group', 'Your Local Time (Manual Entry)').within(() => {
cy.get('input#time_input').type('abc').blur();
// * Verify error state
cy.get('input#time_input').should('have.class', 'error');
});
// # Type valid time
cy.contains('.form-group', 'Your Local Time (Manual Entry)').within(() => {
cy.get('input#time_input').clear().type('2:30pm').blur();
// * Verify error clears
cy.get('input#time_input').should('not.have.class', 'error');
});
});
it('MM-T2530R - Timezone support (dropdown)', function() {
// Skip if running in London timezone (can't test timezone conversion)
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (userTimezone === 'Europe/London' || userTimezone === 'GMT' || userTimezone.includes('London')) {
this.skip();
}
openDateTimeDialog('timezone-manual');
// * Verify timezone indicator is shown
cy.contains('.form-group', 'London Office Hours (Dropdown)').within(() => {
cy.contains('Times in GMT').should('be.visible');
});
// # Select a date
cy.contains('.form-group', 'London Office Hours (Dropdown)').within(() => {
cy.get('.dateTime__date .date-time-input').click();
});
cy.get('.rdp').should('be.visible');
selectDateFromPicker('15');
// # Open time dropdown
cy.contains('.form-group', 'London Office Hours (Dropdown)').within(() => {
cy.get('.dateTime__time button[data-testid="time_button"]').click();
});
// * Verify dropdown shows times starting at midnight (London time)
cy.get('[id="expiryTimeMenu"]').should('be.visible');
cy.get('[id^="time_option_"]').first().invoke('text').then((text) => {
// Should show midnight in 12h or 24h format
expect(text).to.match(/^(12:00 AM|00:00)$/);
});
// # Select a time
cy.get('[id^="time_option_"]').eq(5).click();
// # Submit form
cy.get('#appsModal').within(() => {
cy.get('#appsModalSubmit').click();
});
// * Verify submission success (UTC conversion verified server-side)
cy.get('#appsModal', {timeout: 10000}).should('not.exist');
});
it('MM-T2530S - Timezone support (manual entry)', function() {
// Skip if running in London timezone
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (userTimezone === 'Europe/London' || userTimezone === 'GMT' || userTimezone.includes('London')) {
this.skip();
}
openDateTimeDialog('timezone-manual');
// * Verify timezone indicator is shown
cy.contains('.form-group', 'London Office Hours (Manual Entry)').within(() => {
cy.contains('Times in GMT').should('be.visible');
});
// # Select date
cy.contains('.form-group', 'London Office Hours (Manual Entry)').within(() => {
cy.get('.dateTime__date .date-time-input').click();
});
cy.get('.rdp').should('be.visible');
selectDateFromPicker('15');
// # Type time in manual entry
cy.contains('.form-group', 'London Office Hours (Manual Entry)').within(() => {
cy.get('input#time_input').clear().type('2:30pm').blur();
// * Verify time is accepted
cy.get('input#time_input').should('not.have.class', 'error');
});
// # Submit form
cy.get('#appsModal').within(() => {
cy.get('#appsModalSubmit').click();
});
// * Verify submission success (timezone conversion happens server-side)
cy.get('#appsModal', {timeout: 10000}).should('not.exist');
});
});

View file

@ -527,6 +527,49 @@ function getDateTimeDialog(triggerId, webhookBaseUrl) {
return getBasicDateTimeDialog(triggerId, webhookBaseUrl);
}
function getTimezoneManualDialog(triggerId, webhookBaseUrl) {
return createDialog(triggerId, webhookBaseUrl, {
callback_id: 'timezone_manual',
title: 'Timezone & Manual Entry Demo',
introduction_text: '**Timezone & Manual Entry Demo**\n\n' +
'This dialog demonstrates timezone support and manual time entry features.',
elements: [
{
display_name: 'Your Local Time (Manual Entry)',
name: 'local_manual',
type: 'datetime',
help_text: 'Type any time: 9am, 14:30, 3:45pm - no rounding',
datetime_config: {
allow_manual_time_entry: true,
},
optional: true,
},
{
display_name: 'London Office Hours (Dropdown)',
name: 'london_dropdown',
type: 'datetime',
help_text: 'Times shown in GMT - select from 60 min intervals',
datetime_config: {
location_timezone: 'Europe/London',
time_interval: 60,
},
optional: true,
},
{
display_name: 'London Office Hours (Manual Entry)',
name: 'london_manual',
type: 'datetime',
help_text: 'Type time in GMT: 9am, 14:30, 3:45pm - no rounding',
datetime_config: {
location_timezone: 'Europe/London',
allow_manual_time_entry: true,
},
optional: true,
},
],
});
}
module.exports = {
getFullDialog,
getSimpleDialog,
@ -544,4 +587,5 @@ module.exports = {
getMinDateConstraintDialog,
getCustomIntervalDialog,
getRelativeDateDialog,
getTimezoneManualDialog,
};

View file

@ -302,6 +302,9 @@ function onDateTimeDialogRequest(req, res) {
case 'relative':
dialog = webhookUtils.getRelativeDateDialog(body.trigger_id, webhookBaseUrl);
break;
case 'timezone-manual':
dialog = webhookUtils.getTimezoneManualDialog(body.trigger_id, webhookBaseUrl);
break;
default:
// Default to basic datetime dialog for backward compatibility
dialog = webhookUtils.getBasicDateTimeDialog(body.trigger_id, webhookBaseUrl);

View file

@ -338,6 +338,16 @@ type Dialog struct {
SourceURL string `json:"source_url,omitempty"`
}
// DialogDateTimeConfig groups date/datetime specific configuration
type DialogDateTimeConfig struct {
// TimeInterval: Minutes between time options in dropdown (default: 60)
TimeInterval int `json:"time_interval,omitempty"`
// LocationTimezone: IANA timezone for display (e.g., "America/Denver", "Asia/Tokyo")
LocationTimezone string `json:"location_timezone,omitempty"`
// AllowManualTimeEntry: Allow manual text entry for time instead of dropdown
AllowManualTimeEntry bool `json:"allow_manual_time_entry,omitempty"`
}
type DialogElement struct {
DisplayName string `json:"display_name"`
Name string `json:"name"`
@ -354,7 +364,11 @@ type DialogElement struct {
Options []*PostActionOptions `json:"options"`
MultiSelect bool `json:"multiselect"`
Refresh bool `json:"refresh,omitempty"`
// Date/datetime field specific properties
// Date/datetime field configuration
DateTimeConfig *DialogDateTimeConfig `json:"datetime_config,omitempty"`
// Simple date/datetime configuration (fallback when datetime_config not provided)
MinDate string `json:"min_date,omitempty"`
MaxDate string `json:"max_date,omitempty"`
TimeInterval int `json:"time_interval,omitempty"`

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

@ -22,12 +22,40 @@ type Props = {
onChange: (name: string, value: string | null) => void;
};
// Helper to get timezone abbreviation (e.g., "MST", "EDT")
const getTimezoneAbbreviation = (timezone: string): string => {
try {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
});
const parts = formatter.formatToParts(now);
const tzPart = parts.find((part) => part.type === 'timeZoneName');
return tzPart?.value || timezone;
} catch {
return timezone;
}
};
const AppsFormDateTimeField: React.FC<Props> = ({
field,
value,
onChange,
}) => {
const timezone = useSelector(getCurrentTimezone);
const userTimezone = useSelector(getCurrentTimezone);
// Extract datetime config with fallback to top-level fields
const config = field.datetime_config || {};
const locationTimezone = config.location_timezone;
const timePickerInterval = config.time_interval ?? field.time_interval ?? DEFAULT_TIME_INTERVAL_MINUTES;
const allowManualTimeEntry = config.allow_manual_time_entry ?? false;
// Use location_timezone if specified, otherwise fall back to user's timezone
const timezone = locationTimezone || userTimezone;
// Show timezone indicator when location_timezone is set
const showTimezoneIndicator = Boolean(locationTimezone);
const momentValue = useMemo(() => {
if (value) {
@ -37,17 +65,20 @@ const AppsFormDateTimeField: React.FC<Props> = ({
}
}
// Default to current time for display only
return timezone ? moment.tz(timezone) : moment();
// No automatic defaults - field starts empty
// Required fields get a default from apps_form_component.tsx
return null;
}, [value, timezone]);
const handleDateTimeChange = useCallback((date: moment.Moment) => {
const handleDateTimeChange = useCallback((date: moment.Moment | null) => {
if (!date) {
onChange(field.name, null);
return;
}
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);
@ -62,13 +93,19 @@ const AppsFormDateTimeField: React.FC<Props> = ({
return (
<div className='apps-form-datetime-input'>
{showTimezoneIndicator && (
<div style={{fontSize: '11px', color: '#888', marginBottom: '8px', fontStyle: 'italic'}}>
{'🌍 Times in ' + getTimezoneAbbreviation(timezone)}
</div>
)}
<DateTimeInput
time={momentValue}
handleChange={handleDateTimeChange}
timezone={timezone}
relativeDate={true}
relativeDate={!locationTimezone}
timePickerInterval={timePickerInterval}
allowPastDates={allowPastDates}
allowManualTimeEntry={allowManualTimeEntry}
/>
</div>
);

View file

@ -413,7 +413,7 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
{showDateAndTimeField && (
<DateTimeInput
time={customExpiryTime}
handleChange={setCustomExpiryTime}
handleChange={(date) => date && setCustomExpiryTime(date)}
timezone={timezone}
setIsInteracting={setIsInteracting}
relativeDate={true}

View file

@ -79,9 +79,11 @@ export default function DateTimePickerModal({
};
}, [isInteracting, onExited]);
const handleChange = useCallback((dateTime: Moment) => {
setDateTime(dateTime);
onChange?.(dateTime);
const handleChange = useCallback((dateTime: Moment | null) => {
if (dateTime) {
setDateTime(dateTime);
onChange?.(dateTime);
}
}, [onChange]);
const handleConfirm = useCallback(() => {

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>
@ -63,11 +63,9 @@ exports[`components/datetime_input/DateTimeInput should match snapshot 1`] = `
<span
class="date-time-input__value"
>
<time
datetime="2025-06-08T12:09:00.000"
>
<span>
12:09 PM
</time>
</span>
</span>
</button>
</div>

View file

@ -7,7 +7,7 @@ import React from 'react';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import * as timezoneUtils from 'utils/timezone';
import DateTimeInput, {getTimeInIntervals, getRoundedTime} from './datetime_input';
import DateTimeInput, {getTimeInIntervals, getRoundedTime, parseTimeString} from './datetime_input';
// Mock timezone utilities
jest.mock('utils/timezone', () => ({
@ -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,202 @@ 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();
});
});
describe('auto-rounding behavior', () => {
it('should auto-round time to interval boundary on mount', () => {
const handleChange = jest.fn();
const unroundedTime = moment('2025-06-08T14:17:00Z'); // 14:17 - not on 30-min boundary
renderWithContext(
<DateTimeInput
time={unroundedTime}
handleChange={handleChange}
timePickerInterval={30}
/>,
);
// Should auto-round 14:17 to 14:30 and call handleChange
expect(handleChange).toHaveBeenCalledTimes(1);
const roundedTime = handleChange.mock.calls[0][0];
expect(roundedTime.minute()).toBe(30);
});
it('should not call handleChange if time is already rounded', () => {
const handleChange = jest.fn();
const roundedTime = moment('2025-06-08T14:30:00Z'); // Already on 30-min boundary
renderWithContext(
<DateTimeInput
time={roundedTime}
handleChange={handleChange}
timePickerInterval={30}
/>,
);
// Should not call handleChange since time is already rounded
expect(handleChange).not.toHaveBeenCalled();
});
it('should use 30-minute default interval when prop not provided', () => {
const handleChange = jest.fn();
const unroundedTime = moment('2025-06-08T14:17:00Z');
renderWithContext(
<DateTimeInput
time={unroundedTime}
handleChange={handleChange}
// No timePickerInterval prop - should use 30-min default
/>,
);
// Should round using default 30-min interval
expect(handleChange).toHaveBeenCalledTimes(1);
const roundedTime = handleChange.mock.calls[0][0];
expect(roundedTime.minute()).toBe(30); // 14:17 -> 14:30
});
});
describe('parseTimeString', () => {
it('should parse 12-hour format with AM/PM', () => {
expect(parseTimeString('12a')).toEqual({hours: 0, minutes: 0}); // 12 AM = 00:00
expect(parseTimeString('12am')).toEqual({hours: 0, minutes: 0});
expect(parseTimeString('1a')).toEqual({hours: 1, minutes: 0});
expect(parseTimeString('11pm')).toEqual({hours: 23, minutes: 0});
expect(parseTimeString('12p')).toEqual({hours: 12, minutes: 0}); // 12 PM = 12:00
expect(parseTimeString('12pm')).toEqual({hours: 12, minutes: 0});
});
it('should parse 12-hour format with minutes', () => {
expect(parseTimeString('3:30pm')).toEqual({hours: 15, minutes: 30});
expect(parseTimeString('3:30 PM')).toEqual({hours: 15, minutes: 30});
expect(parseTimeString('9:15am')).toEqual({hours: 9, minutes: 15});
expect(parseTimeString('12:45am')).toEqual({hours: 0, minutes: 45});
expect(parseTimeString('12:30pm')).toEqual({hours: 12, minutes: 30});
});
it('should parse 24-hour format', () => {
expect(parseTimeString('00:00')).toEqual({hours: 0, minutes: 0});
expect(parseTimeString('14:30')).toEqual({hours: 14, minutes: 30});
expect(parseTimeString('23:59')).toEqual({hours: 23, minutes: 59});
expect(parseTimeString('9:15')).toEqual({hours: 9, minutes: 15});
});
it('should parse time without minutes (defaults to :00)', () => {
expect(parseTimeString('14')).toEqual({hours: 14, minutes: 0});
expect(parseTimeString('9')).toEqual({hours: 9, minutes: 0});
});
it('should handle various spacing and case', () => {
expect(parseTimeString(' 3:30pm ')).toEqual({hours: 15, minutes: 30});
expect(parseTimeString('3:30PM')).toEqual({hours: 15, minutes: 30});
expect(parseTimeString('3:30 pm')).toEqual({hours: 15, minutes: 30});
});
it('should reject invalid hour values', () => {
expect(parseTimeString('25:00')).toBeNull(); // 25 hours invalid
expect(parseTimeString('24:00')).toBeNull(); // 24 hours invalid
expect(parseTimeString('13pm')).toBeNull(); // 13 PM invalid
expect(parseTimeString('0am')).toBeNull(); // 0 AM invalid
});
it('should reject invalid minute values', () => {
expect(parseTimeString('3:60pm')).toBeNull(); // 60 minutes invalid
expect(parseTimeString('14:99')).toBeNull();
expect(parseTimeString('3:-5pm')).toBeNull();
});
it('should reject invalid formats', () => {
expect(parseTimeString('abc')).toBeNull();
expect(parseTimeString('12:34:56')).toBeNull(); // Seconds not supported
expect(parseTimeString('pm')).toBeNull();
expect(parseTimeString('')).toBeNull();
expect(parseTimeString(null as any)).toBeNull();
});
it('should handle edge cases at midnight and noon', () => {
expect(parseTimeString('12:00am')).toEqual({hours: 0, minutes: 0}); // Midnight
expect(parseTimeString('12:01am')).toEqual({hours: 0, minutes: 1});
expect(parseTimeString('11:59pm')).toEqual({hours: 23, minutes: 59});
expect(parseTimeString('12:00pm')).toEqual({hours: 12, minutes: 0}); // Noon
expect(parseTimeString('12:59pm')).toEqual({hours: 12, minutes: 59});
});
});
describe('timezone handling', () => {
it('should preserve timezone when generating time intervals', () => {
const londonTime = moment.tz('2025-06-08T14:00:00', 'Europe/London');
const intervals = getTimeInIntervals(londonTime, 60);
// All intervals should preserve London timezone
expect(intervals.length).toBeGreaterThan(0);
intervals.forEach((interval) => {
expect(interval.tz()).toBe('Europe/London');
});
});
it('should generate intervals starting at midnight in specified timezone', () => {
const londonMidnight = moment.tz('2025-06-08', 'Europe/London').startOf('day');
const intervals = getTimeInIntervals(londonMidnight, 60);
// First interval should be midnight in London
expect(intervals[0].format('HH:mm')).toBe('00:00');
expect(intervals[0].tz()).toBe('Europe/London');
});
it('should handle timezone conversion in parseTimeString and moment creation', () => {
// This tests the pattern used in TimeInputManual
const parsed = parseTimeString('3:45pm');
expect(parsed).toEqual({hours: 15, minutes: 45});
// Create moment in specific timezone
const londonTime = moment.tz([2025, 5, 8, parsed!.hours, parsed!.minutes, 0, 0], 'Europe/London');
expect(londonTime.tz()).toBe('Europe/London');
expect(londonTime.format('HH:mm')).toBe('15:45');
});
});
});

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,12 +9,13 @@ 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';
@ -32,13 +32,13 @@ export function getRoundedTime(value: Moment, roundedTo = CUSTOM_STATUS_TIME_PIC
return start.add(remainder, 'm').seconds(0).milliseconds(0);
}
export const getTimeInIntervals = (startTime: Moment, interval = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES): Date[] => {
export const getTimeInIntervals = (startTime: Moment, interval = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES): Moment[] => {
let time = moment(startTime);
const nextDay = moment(startTime).add(1, 'days').startOf('day');
const intervals: Date[] = [];
const intervals: Moment[] = [];
while (time.isBefore(nextDay)) {
intervals.push(time.toDate());
intervals.push(time.clone()); // Clone to preserve moment with timezone
const utcOffset = time.utcOffset();
time = time.add(interval, 'minutes').seconds(0).milliseconds(0);
@ -51,14 +51,179 @@ export const getTimeInIntervals = (startTime: Moment, interval = CUSTOM_STATUS_T
return intervals;
};
// Parse time string - supports HH:MM, H:MM, 12am, 12:30pm, 14:30, etc.
// No rounding - returns exact parsed hours and minutes
export const parseTimeString = (input: string): {hours: number; minutes: number} | null => {
if (!input || typeof input !== 'string') {
return null;
}
const trimmed = input.trim().toLowerCase();
// Check for AM/PM
const hasAM = (/am?$/).test(trimmed);
const hasPM = (/pm?$/).test(trimmed);
const is12Hour = hasAM || hasPM;
// Remove AM/PM and extra spaces
const timeStr = trimmed.replace(/[ap]m?$/i, '').trim();
// Match time formats: HH:MM, H:MM, HH, H
const match = timeStr.match(/^(\d{1,2}):?(\d{2})?$/);
if (!match) {
return null;
}
let hours = parseInt(match[1], 10);
const minutes = match[2] ? parseInt(match[2], 10) : 0;
// Validate ranges
if (minutes < 0 || minutes > 59) {
return null;
}
if (is12Hour) {
// 12-hour format validation
if (hours < 1 || hours > 12) {
return null;
}
// Convert to 24-hour
if (hasAM) {
if (hours === 12) {
hours = 0; // 12 AM = 00:00
}
} else if (hasPM) {
if (hours !== 12) {
hours += 12; // 1 PM = 13:00, but 12 PM stays 12
}
}
} else if (hours < 0 || hours > 23) {
// 24-hour format validation
return null;
}
return {hours, minutes};
};
// TimeInputManual - Manual text entry for time (simplified - no rounding, no auto-advance)
type TimeInputManualProps = {
time: Moment | null;
timezone?: string;
isMilitaryTime: boolean;
onTimeChange: (time: Moment) => void;
}
const TimeInputManual: React.FC<TimeInputManualProps> = ({
time,
timezone,
isMilitaryTime,
onTimeChange,
}) => {
const {formatMessage} = useIntl();
const [timeInputValue, setTimeInputValue] = useState<string>('');
const [timeInputError, setTimeInputError] = useState<boolean>(false);
const timeInputRef = useRef<HTMLInputElement>(null);
// Sync input value with time prop changes
useEffect(() => {
if (time) {
const formatted = time.format(isMilitaryTime ? 'HH:mm' : 'h:mm A');
setTimeInputValue(formatted);
} else {
setTimeInputValue('');
}
}, [time, isMilitaryTime]);
const handleTimeInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setTimeInputValue(event.target.value);
setTimeInputError(false); // Clear error as user types
}, []);
const handleTimeInputBlur = useCallback(() => {
const parsed = parseTimeString(timeInputValue);
if (!parsed) {
if (timeInputValue.trim() !== '') {
setTimeInputError(true);
}
return;
}
// Create a moment with the parsed time on the current date (no rounding)
const baseMoment = time ? time.clone() : getCurrentMomentForTimezone(timezone);
let targetMoment: Moment;
if (timezone) {
targetMoment = moment.tz([
baseMoment.year(),
baseMoment.month(),
baseMoment.date(),
parsed.hours,
parsed.minutes,
0,
0,
], timezone);
} else {
baseMoment.hour(parsed.hours);
baseMoment.minute(parsed.minutes);
baseMoment.second(0);
baseMoment.millisecond(0);
targetMoment = baseMoment;
}
// Valid time - update (no auto-advance, no exclusion checking)
onTimeChange(targetMoment);
setTimeInputError(false);
}, [timeInputValue, time, timezone, onTimeChange]);
const handleTimeInputKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (isKeyPressed(event as any, Constants.KeyCodes.ENTER)) {
event.preventDefault();
timeInputRef.current?.blur(); // Trigger validation
}
}, []);
return (
<div className='date-time-input-manual'>
<label
htmlFor='time_input'
className='date-time-input__label'
>
{formatMessage({
id: 'datetime.time',
defaultMessage: 'Time',
})}
</label>
<input
ref={timeInputRef}
id='time_input'
type='text'
className={`date-time-input__text-input${timeInputError ? ' error' : ''}`}
value={timeInputValue}
onChange={handleTimeInputChange}
onBlur={handleTimeInputBlur}
onKeyDown={handleTimeInputKeyDown}
placeholder={isMilitaryTime ? '13:40' : '1:40 PM'}
aria-label={formatMessage({
id: 'datetime.time',
defaultMessage: 'Time',
})}
/>
</div>
);
};
type Props = {
time: Moment;
handleChange: (date: Moment) => void;
time: Moment | null;
handleChange: (date: Moment | null) => void;
timezone?: string;
setIsInteracting?: (interacting: boolean) => void;
relativeDate?: boolean;
timePickerInterval?: number;
allowPastDates?: boolean;
allowManualTimeEntry?: boolean;
}
const DateTimeInputContainer: React.FC<Props> = ({
@ -69,9 +234,13 @@ const DateTimeInputContainer: React.FC<Props> = ({
relativeDate,
timePickerInterval,
allowPastDates = false,
allowManualTimeEntry = false,
}: Props) => {
const currentTime = getCurrentMomentForTimezone(timezone);
const displayTime = time; // No automatic default - field stays null until user selects
const locale = useSelector(getCurrentLocale);
const [timeOptions, setTimeOptions] = useState<Date[]>([]);
const isMilitaryTime = useSelector(isUseMilitaryTime);
const [timeOptions, setTimeOptions] = useState<Moment[]>([]);
const [isPopperOpen, setIsPopperOpen] = useState(false);
const [isTimeMenuOpen, setIsTimeMenuOpen] = useState(false);
const [menuWidth, setMenuWidth] = useState<string>('200px');
@ -97,9 +266,10 @@ const DateTimeInputContainer: React.FC<Props> = ({
}
}, [setIsInteracting]);
const handleTimeChange = useCallback((time: Date) => {
handleChange(timezone ? moment.tz(time, timezone) : moment(time));
}, [handleChange, timezone]);
const handleTimeChange = useCallback((selectedTime: Moment) => {
// selectedTime is already a Moment with correct timezone from getTimeInIntervals
handleChange(selectedTime.clone().second(0).millisecond(0));
}, [handleChange]);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// Handle escape key for date picker when time menu is not open
@ -118,42 +288,92 @@ const DateTimeInputContainer: React.FC<Props> = ({
};
}, [handleKeyDown]);
// Auto-round time if it's not already on an interval boundary
// This ensures consistent behavior across all callers (DND, Custom Status, Post Reminder, etc.)
// Uses default 30-minute interval if not specified
// Skip for manual entry fields (user types exact minutes)
useEffect(() => {
if (time && !allowManualTimeEntry) {
const interval = timePickerInterval || CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES;
const rounded = getRoundedTime(time, interval);
// Only update if the time actually needs rounding
if (!rounded.isSame(time, 'minute')) {
handleChange(rounded);
}
}
}, [time, timePickerInterval, handleChange, allowManualTimeEntry]);
const setTimeAndOptions = () => {
const currentTime = getCurrentMomentForTimezone(timezone);
let startTime = moment(time).startOf('day');
// Use displayTime if available, otherwise use currentTime for generating dropdown
// This ensures dropdown always has options even for optional fields with null time
const timeForOptions = displayTime || currentTime;
// Use clone() to preserve timezone information
let startTime = timeForOptions.clone().startOf('day');
// For form fields (allowPastDates=true), always start from beginning of day
// For scheduling (allowPastDates=false), restrict to current time if today
if (!allowPastDates && currentTime.isSame(time, 'date')) {
if (!allowPastDates && currentTime.isSame(timeForOptions, 'date')) {
startTime = getRoundedTime(currentTime, timePickerInterval);
}
setTimeOptions(getTimeInIntervals(startTime, timePickerInterval));
};
useEffect(setTimeAndOptions, [time]);
useEffect(setTimeAndOptions, [displayTime, timePickerInterval, allowPastDates, timezone]);
const handleDayChange = (day: Date, modifiers: DayModifiers) => {
// Use existing time if available, otherwise use current time in display timezone
let effectiveTime = displayTime;
if (!effectiveTime) {
// Get current time in the display timezone
const nowInTimezone = getCurrentMomentForTimezone(timezone);
// For manual entry, use exact time (no rounding)
// For dropdown, use rounded time
effectiveTime = allowManualTimeEntry ?
nowInTimezone :
getRoundedTime(nowInTimezone, timePickerInterval || 60);
}
if (modifiers.today) {
const baseTime = getCurrentMomentForTimezone(timezone);
if (!allowPastDates && isBeforeTime(baseTime, time)) {
baseTime.hour(time.hours());
baseTime.minute(time.minutes());
if (!allowPastDates && isBeforeTime(baseTime, effectiveTime)) {
baseTime.hour(effectiveTime.hours());
baseTime.minute(effectiveTime.minutes());
}
const roundedTime = getRoundedTime(baseTime, timePickerInterval);
handleChange(roundedTime);
} else if (timezone) {
// Use moment.tz array syntax to create moment directly in timezone
// This is the same pattern used by manual entry (which works correctly)
const dayMoment = moment(day);
const targetDate = moment.tz([
dayMoment.year(),
dayMoment.month(),
dayMoment.date(),
effectiveTime.hour(),
effectiveTime.minute(),
0,
0,
], timezone);
handleChange(targetDate);
} else {
day.setHours(time.hour(), time.minute());
const dayWithTimezone = timezone ? moment(day).tz(timezone, true) : moment(day);
handleChange(dayWithTimezone);
day.setHours(effectiveTime.hour(), effectiveTime.minute());
handleChange(moment(day));
}
handlePopperOpenState(false);
};
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 = (
@ -167,12 +387,10 @@ const DateTimeInputContainer: React.FC<Props> = ({
const datePickerProps: DayPickerProps = {
initialFocus: isPopperOpen,
mode: 'single',
selected: time.toDate(),
defaultMonth: time.toDate(),
selected: displayTime?.toDate(),
defaultMonth: displayTime?.toDate() || new Date(),
onDayClick: handleDayChange,
disabled: allowPastDates ? undefined : [{
before: currentTime,
}],
disabled: allowPastDates ? undefined : {before: currentTime.toDate()},
showOutsideDays: true,
};
@ -189,67 +407,75 @@ const DateTimeInputContainer: React.FC<Props> = ({
defaultMessage: 'Date',
})}
icon={calendarIcon}
value={formatDate(time)}
value={displayTime ? formatDate(displayTime) : ''}
>
<></>
<span className='date-time-input__placeholder'>
{formatMessage({
id: 'datetime.select_date',
defaultMessage: 'Select date',
})}
</span>
</DatePicker>
</div>
<div
className='dateTime__time'
ref={timeContainerRef}
>
<Menu.Container
menuButton={{
id: 'time_button',
dataTestId: 'time_button',
'aria-label': formatMessage({
id: 'datetime.time',
defaultMessage: 'Time',
}),
class: isTimeMenuOpen ? 'date-time-input date-time-input--open' : 'date-time-input',
children: (
<>
<span className='date-time-input__label'>{formatMessage({
id: 'datetime.time',
defaultMessage: 'Time',
})}</span>
<span className='date-time-input__icon'>{clockIcon}</span>
<span className='date-time-input__value'>
<Timestamp
useRelative={false}
useDate={false}
value={time.toString()}
/>
</span>
</>
),
}}
menu={{
id: 'expiryTimeMenu',
'aria-label': formatMessage({id: 'time_dropdown.choose_time', defaultMessage: 'Choose a time'}),
onToggle: handleTimeMenuToggle,
width: menuWidth,
className: 'time-menu-scrollable',
}}
>
{timeOptions.map((option, index) => (
<Menu.Item
key={index}
id={`time_option_${index}`}
data-testid={`time_option_${index}`}
labels={
<span>
<Timestamp
useRelative={false}
useDate={false}
value={option}
/>
</span>
}
onClick={() => handleTimeChange(option)}
/>
))}
</Menu.Container>
{allowManualTimeEntry ? (
<TimeInputManual
time={displayTime}
timezone={timezone}
isMilitaryTime={isMilitaryTime}
onTimeChange={handleTimeChange}
/>
) : (
<Menu.Container
menuButton={{
id: 'time_button',
dataTestId: 'time_button',
'aria-label': formatMessage({
id: 'datetime.time',
defaultMessage: 'Time',
}),
class: isTimeMenuOpen ? 'date-time-input date-time-input--open' : 'date-time-input',
children: (
<>
<span className='date-time-input__label'>{formatMessage({
id: 'datetime.time',
defaultMessage: 'Time',
})}</span>
<span className='date-time-input__icon'>{clockIcon}</span>
<span className='date-time-input__value'>
{displayTime ? (
<span>{displayTime.format(isMilitaryTime ? 'HH:mm' : 'LT')}</span>
) : (
<span>{'--:--'}</span>
)}
</span>
</>
),
}}
menu={{
id: 'expiryTimeMenu',
'aria-label': formatMessage({id: 'time_dropdown.choose_time', defaultMessage: 'Choose a time'}),
onToggle: handleTimeMenuToggle,
width: menuWidth,
className: 'time-menu-scrollable',
}}
>
{timeOptions.map((option, index) => (
<Menu.Item
key={index}
id={`time_option_${index}`}
data-testid={`time_option_${index}`}
labels={
<span>{option.format(isMilitaryTime ? 'HH:mm' : 'LT')}</span>
}
onClick={() => handleTimeChange(option)}
/>
))}
</Menu.Container>
)}
</div>
</div>
);

View file

@ -102,10 +102,12 @@ export default injectIntl(class DndCustomTimePicker extends React.PureComponent<
this.props.onExited();
};
handleDateTimeChange = (newDateTime: moment.Moment) => {
this.setState({
selectedDateTime: newDateTime,
});
handleDateTimeChange = (newDateTime: moment.Moment | null) => {
if (newDateTime) {
this.setState({
selectedDateTime: newDateTime,
});
}
};
render() {

View file

@ -4193,6 +4193,7 @@
"date_separator.tomorrow": "Tomorrow",
"date_separator.yesterday": "Yesterday",
"datetime.date": "Date",
"datetime.select_date": "Select date",
"datetime.time": "Time",
"datetime.today": "today",
"datetime.yesterday": "yesterday",

View file

@ -223,3 +223,40 @@ input::-webkit-file-upload-button {
max-height: 300px;
overflow-y: auto;
}
// Manual time entry styles
.date-time-input-manual {
display: flex;
flex-direction: column;
gap: 4px;
.date-time-input__label {
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 12px;
font-weight: 600;
}
.date-time-input__text-input {
padding: 8px 12px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
background: var(--center-channel-bg);
color: var(--center-channel-color);
font-size: 14px;
&:focus {
border-color: var(--button-bg);
box-shadow: 0 0 0 2px rgba(var(--button-bg-rgb), 0.12);
outline: none;
}
&.error {
border-color: var(--error-text);
box-shadow: 0 0 0 2px rgba(var(--error-text-color-rgb), 0.12);
}
&::placeholder {
color: rgba(var(--center-channel-color-rgb), 0.48);
}
}
}

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,8 @@ export function momentToString(momentValue: Moment | null, isDateTime: boolean):
}
if (isDateTime) {
return momentValue.utc().format(MOMENT_DATETIME_FORMAT);
// Clone to avoid mutating the original moment when converting to UTC
return momentValue.clone().utc().format(MOMENT_DATETIME_FORMAT);
}
// Store date only: "2025-01-14"

View file

@ -438,8 +438,14 @@ export function convertElement(element: DialogElement, options: ConversionOption
}
}
// Add date/datetime specific properties (new features that should pass through)
// Add date/datetime specific properties
if (element.type === DialogElementTypes.DATE || element.type === DialogElementTypes.DATETIME) {
// Use datetime_config if provided
if (element.datetime_config) {
appField.datetime_config = element.datetime_config;
}
// Simple fallback fields (used when datetime_config is not provided)
if (element.min_date !== undefined) {
appField.min_date = String(element.min_date);
}

View file

@ -437,6 +437,13 @@ function isAppSelectOption(v: unknown): v is AppSelectOption {
export type AppFieldType = string;
// DateTime field configuration
export type DateTimeConfig = {
time_interval?: number; // Minutes between time options (default: 60)
location_timezone?: string; // IANA timezone for display (e.g., "America/Denver", "Asia/Tokyo")
allow_manual_time_entry?: boolean; // Allow text entry for time
};
// This should go in mattermost-redux
export type AppField = {
@ -468,7 +475,10 @@ export type AppField = {
min_length?: number;
max_length?: number;
// Date props
// Date/datetime configuration
datetime_config?: DateTimeConfig;
// Simple date/datetime configuration (fallback when datetime_config not provided)
min_date?: string;
max_date?: string;
time_interval?: number;

View file

@ -196,6 +196,15 @@ export type DialogElement = {
value: any;
}>;
refresh?: boolean;
// Date/datetime configuration
datetime_config?: {
time_interval?: number;
location_timezone?: string;
allow_manual_time_entry?: boolean;
};
// Simple date/datetime configuration (fallback when datetime_config not provided)
min_date?: string;
max_date?: string;
time_interval?: number;