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..84d6e50fdf1 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,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'); + }); }); diff --git a/e2e-tests/cypress/utils/webhook_utils.js b/e2e-tests/cypress/utils/webhook_utils.js index 9abfd522437..4b8331f13c4 100644 --- a/e2e-tests/cypress/utils/webhook_utils.js +++ b/e2e-tests/cypress/utils/webhook_utils.js @@ -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, }; diff --git a/e2e-tests/cypress/webhook_serve.js b/e2e-tests/cypress/webhook_serve.js index 27d17520bfb..eda29d03ca5 100644 --- a/e2e-tests/cypress/webhook_serve.js +++ b/e2e-tests/cypress/webhook_serve.js @@ -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); diff --git a/server/public/model/integration_action.go b/server/public/model/integration_action.go index 83728743b81..c64a26af242 100644 --- a/server/public/model/integration_action.go +++ b/server/public/model/integration_action.go @@ -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"` 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..69c3264201c 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 @@ -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 = ({ 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 = ({ } } - // 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 = ({ return (
+ {showTimezoneIndicator && ( +
+ {'🌍 Times in ' + getTimezoneAbbreviation(timezone)} +
+ )}
); diff --git a/webapp/channels/src/components/custom_status/custom_status_modal.tsx b/webapp/channels/src/components/custom_status/custom_status_modal.tsx index 0cc4dec9d45..27853295cad 100644 --- a/webapp/channels/src/components/custom_status/custom_status_modal.tsx +++ b/webapp/channels/src/components/custom_status/custom_status_modal.tsx @@ -413,7 +413,7 @@ const CustomStatusModal: React.FC = (props: Props) => { {showDateAndTimeField && ( date && setCustomExpiryTime(date)} timezone={timezone} setIsInteracting={setIsInteracting} relativeDate={true} diff --git a/webapp/channels/src/components/date_time_picker_modal/date_time_picker_modal.tsx b/webapp/channels/src/components/date_time_picker_modal/date_time_picker_modal.tsx index 8be5d45f5f0..96db8721ccd 100644 --- a/webapp/channels/src/components/date_time_picker_modal/date_time_picker_modal.tsx +++ b/webapp/channels/src/components/date_time_picker_modal/date_time_picker_modal.tsx @@ -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(() => { 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..10b5daf3625 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 @@ -63,11 +63,9 @@ exports[`components/datetime_input/DateTimeInput should match snapshot 1`] = ` - + 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..afd5826e3f9 100644 --- a/webapp/channels/src/components/datetime_input/datetime_input.test.tsx +++ b/webapp/channels/src/components/datetime_input/datetime_input.test.tsx @@ -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; const mockIsBeforeTime = timezoneUtils.isBeforeTime as jest.MockedFunction; @@ -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(); + + // 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(); + }); + }); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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'); + }); + }); }); diff --git a/webapp/channels/src/components/datetime_input/datetime_input.tsx b/webapp/channels/src/components/datetime_input/datetime_input.tsx index fe338576ac3..957300429ef 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,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 = ({ + time, + timezone, + isMilitaryTime, + onTimeChange, +}) => { + const {formatMessage} = useIntl(); + const [timeInputValue, setTimeInputValue] = useState(''); + const [timeInputError, setTimeInputError] = useState(false); + const timeInputRef = useRef(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) => { + 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) => { + if (isKeyPressed(event as any, Constants.KeyCodes.ENTER)) { + event.preventDefault(); + timeInputRef.current?.blur(); // Trigger validation + } + }, []); + + return ( +
+ + +
+ ); +}; + 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 = ({ @@ -69,9 +234,13 @@ const DateTimeInputContainer: React.FC = ({ 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([]); + const isMilitaryTime = useSelector(isUseMilitaryTime); + const [timeOptions, setTimeOptions] = useState([]); const [isPopperOpen, setIsPopperOpen] = useState(false); const [isTimeMenuOpen, setIsTimeMenuOpen] = useState(false); const [menuWidth, setMenuWidth] = useState('200px'); @@ -97,9 +266,10 @@ const DateTimeInputContainer: React.FC = ({ } }, [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 = ({ }; }, [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 = ({ 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 = ({ defaultMessage: 'Date', })} icon={calendarIcon} - value={formatDate(time)} + value={displayTime ? formatDate(displayTime) : ''} > - <> + + {formatMessage({ + id: 'datetime.select_date', + defaultMessage: 'Select date', + })} +
- - {formatMessage({ - id: 'datetime.time', - defaultMessage: 'Time', - })} - {clockIcon} - - - - - ), - }} - 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) => ( - - - - } - onClick={() => handleTimeChange(option)} - /> - ))} - + {allowManualTimeEntry ? ( + + ) : ( + + {formatMessage({ + id: 'datetime.time', + defaultMessage: 'Time', + })} + {clockIcon} + + {displayTime ? ( + {displayTime.format(isMilitaryTime ? 'HH:mm' : 'LT')} + ) : ( + {'--:--'} + )} + + + ), + }} + 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) => ( + {option.format(isMilitaryTime ? 'HH:mm' : 'LT')} + } + onClick={() => handleTimeChange(option)} + /> + ))} + + )}
); diff --git a/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.tsx b/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.tsx index 3b27ce4698b..8be4e2dbb11 100644 --- a/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.tsx +++ b/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.tsx @@ -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() { diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index cb21d59ffd5..c060a282d03 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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", diff --git a/webapp/channels/src/sass/components/_inputs.scss b/webapp/channels/src/sass/components/_inputs.scss index 47cf099971b..bf3076822f5 100644 --- a/webapp/channels/src/sass/components/_inputs.scss +++ b/webapp/channels/src/sass/components/_inputs.scss @@ -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); + } + } +} 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..5b8e7a7e05f 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,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" diff --git a/webapp/channels/src/utils/dialog_conversion.ts b/webapp/channels/src/utils/dialog_conversion.ts index 4e288138d7d..080de1144a3 100644 --- a/webapp/channels/src/utils/dialog_conversion.ts +++ b/webapp/channels/src/utils/dialog_conversion.ts @@ -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); } diff --git a/webapp/platform/types/src/apps.ts b/webapp/platform/types/src/apps.ts index 16e69485b17..2b61571f0fe 100644 --- a/webapp/platform/types/src/apps.ts +++ b/webapp/platform/types/src/apps.ts @@ -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; diff --git a/webapp/platform/types/src/integrations.ts b/webapp/platform/types/src/integrations.ts index dc1a8c45c25..03edc0bc870 100644 --- a/webapp/platform/types/src/integrations.ts +++ b/webapp/platform/types/src/integrations.ts @@ -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;