From 53aa05d8c69ea899c1d0e9699008633643a3fe43 Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Mon, 16 Feb 2026 13:32:31 -0700 Subject: [PATCH] Interactive Dialog - DateTime manual entry and timezone support (#34932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Respect user display preferences for date and time formatting User Preference Support: - Read isUseMilitaryTime preference from user settings - Apply 24-hour format when enabled (14:00 instead of 2:00 PM) - Apply 12-hour format when disabled (2:00 PM instead of 14:00) - Pass useTime prop to Timestamp component with correct hourCycle/hour12 Date Formatting Consistency: - Create formatDateForDisplay() utility in date_utils.ts - Centralize date formatting logic (month: 'short', day/year: 'numeric') - Use consistent "Jan 15, 2025" format across all date/datetime fields - Replace DateTime.fromJSDate().toLocaleString() which varies by browser Components Updated: - DateTimeInput: Use isMilitaryTime for dropdown and selected time display - DateTimeInput: Use formatDateForDisplay for date display - AppsFormDateField: Use formatDateForDisplay instead of inline Intl code Tests Added: - 4 tests for user preference handling (military time, locale) - 5 tests for formatDateForDisplay utility - Updated snapshot for Timestamp changes Benefits: - Single source of truth for date formatting - Easy to change format globally by updating one function - Respects user preferences consistently - Fixes inconsistency where datetime showed "1/1/2026" vs date showing "Jan 1, 2026" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) * Add timezone and manual time entry support for datetime fields Object Model: - Create DialogDateTimeConfig with TimeInterval, LocationTimezone, AllowManualTimeEntry - Add DateTimeConfig field to DialogElement - Keep legacy MinDate, MaxDate, TimeInterval for fallback Timezone Support (location_timezone): - Display datetime in specific IANA timezone (e.g., "Europe/London", "Asia/Tokyo") - Show timezone indicator: "🌍 Times in GMT" - Preserve timezone through all operations - Fix momentToString to clone before converting to UTC (prevents mutation) - Use moment.tz array syntax for timezone-safe moment creation - Generate time intervals starting at midnight in display timezone Manual Time Entry (allow_manual_time_entry): - Add parseTimeString() function supporting multiple formats: - 12-hour: 12a, 12:30p, 3:45pm - 24-hour: 14:30, 9:15 - Add TimeInputManual component with text input - Conditional rendering: manual input OR dropdown - No rounding for manual entry (exact minutes preserved) - No auto-advance (validation only, show error for invalid format) - Respects user's 12h/24h preference for placeholder Critical Bug Fixes: - Fix getTimeInIntervals to return Moment[] instead of Date[] (preserves timezone) - Fix momentToString mutation: use .clone() before .utc() - Use .clone() when calling .startOf('day') to preserve timezone - Use moment.tz([...], timezone) array syntax instead of .tz().hour() mutation - Display selected time using .format() instead of Timestamp component - Fix null handling: optional fields start empty, show '--:--' - Manual entry gets exact current time, dropdown gets rounded time Component Updates: - DateTimeInput: Add TimeInputManual component, parseTimeString, timezone handling - AppsFormDateTimeField: Extract config, timezone indicator, pass timezone to child - Modal components: Handle Moment | null signatures - CSS: Add manual entry input styles with error states Features: - Timezone-aware time generation (dropdown starts at midnight in display TZ) - Manual entry works with timezones (creates moments in correct TZ) - Optional fields start empty (null value, no display default) - Required datetime fields get rounded default from apps_form_component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) * Fix momentToString mutation - clone before converting to UTC Prevents .utc() from mutating the original moment object. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) * Add E2E test for 12h/24h time preference support Test MM-T2530H verifies that datetime fields respect user's display preference: - Sets preference to 24-hour format - Verifies dropdown shows times as 14:00, 15:00, etc. - Verifies selected time displays in 24-hour format - Changes preference to 12-hour format - Verifies dropdown shows times as 2:00 PM, 3:00 PM, etc. Uses cy.apiSaveClockDisplayModeTo24HourPreference() to set user preference. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) * Auto-round time to interval boundaries in DateTimeInput Automatically rounds displayed time to timePickerInterval to ensure consistent behavior across all callers. Problem: - DND modal and Custom Status modal showed unrounded times (e.g., 13:47) - Should show rounded times (e.g., 14:00) to match dropdown intervals - Some callers pre-rounded, others didn't (inconsistent) Solution: - Add useEffect in DateTimeInput that auto-rounds on mount - Only calls handleChange if time needs rounding - Uses timePickerInterval prop or 30-minute default - Harmless for callers that already pre-round (no change triggered) Behavior: - DND modal: Now shows 14:00 instead of 13:47 - Custom Status: Still works (already pre-rounded, so no-op) - Post Reminder: Still works (already pre-rounded, so no-op) - Interactive Dialog: Still works (uses custom intervals) Added 3 unit tests for auto-rounding behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) * lint fix * Add deferred login cleanup to post_test.go 'not logged in' test Ensures the test helper is logged back in after the logout test completes, preventing test state issues for subsequent tests. Co-Authored-By: Claude Sonnet 4.5 (1M context) * Add unit tests for parseTimeString and timezone handling parseTimeString tests (9 test cases): - 12-hour format with AM/PM (12a, 3:30pm, etc.) - 24-hour format (14:30, 23:59, etc.) - Time without minutes (defaults to :00) - Invalid hours, minutes, and formats - Edge cases (midnight 12:00am, noon 12:00pm) Timezone handling tests (3 test cases): - Preserve timezone in getTimeInIntervals - Generate intervals starting at midnight in timezone - Timezone conversion pattern verification Total: 12 new tests added (32 total in file) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) * Add E2E tests and webhook support for timezone/manual entry E2E Tests Added (MM-T2530O through MM-T2530S): - MM-T2530O: Manual time entry basic functionality - MM-T2530P: Manual time entry multiple formats (12a, 14:30, 9pm) - MM-T2530Q: Manual time entry invalid format handling - MM-T2530R: Timezone support dropdown (London GMT) - MM-T2530S: Timezone support manual entry (London GMT) Webhook Server Support: - Added getTimezoneManualDialog() to webhook_utils.js - Added 'timezone-manual' case to webhook_serve.js - Dialog with 3 fields: local manual, London dropdown, London manual Bug Fixes: - Skip auto-rounding for allowManualTimeEntry fields (preserve exact minutes) - Generate dropdown options even when displayTime is null (use currentTime fallback) - Scope Cypress selectors with .within() to avoid duplicate ID issues All tests passing (13 total datetime tests). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) * Fix ESLint no-multi-spaces in apps.ts Remove extra spacing before comments to comply with ESLint rules. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) * Fix gofmt formatting in integration_action.go Align Options, MultiSelect, and Refresh field spacing to match Go formatting standards. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) * more lint fixes * css lint fix * i18n-extract * lint fixes * update snapshot * Fix modal scroll containment for datetime fields The .modal-overflow class was applying overflow: visible to .modal-body, which broke scroll containment when datetime fields were present. This caused the entire form to scroll instead of scrolling within the modal-body viewport. Changes: - Remove .modal-body overflow override from .modal-overflow class to preserve scroll containment while still allowing date/time popups to display correctly via z-index - Remove italic styling from timezone indicator for cleaner appearance - Remove redundant "Time" label from manual time entry input (aria-label is sufficient for accessibility) - Add CSS rule to ensure "(optional)" label text is not bold Co-Authored-By: Claude Sonnet 4.5 (1M context) * fixes for cypress tests * fix for using timezone crossing dates * fix dateonly strings parse failures * regex fix * linter fix --------- Co-authored-by: Claude Sonnet 4.5 (1M context) Co-authored-by: Mattermost Build --- .../interactive_dialog/datetime_spec.js | 159 ++++++++ e2e-tests/cypress/utils/webhook_utils.js | 44 ++ e2e-tests/cypress/webhook_serve.js | 3 + server/public/model/integration_action.go | 16 +- .../apps_form/apps_form_component.tsx | 12 +- .../apps_form_date_field.tsx | 5 +- .../apps_form_datetime_field.tsx | 65 ++- .../apps_form_field/apps_form_field.tsx | 3 + .../custom_status/custom_status_modal.tsx | 2 +- .../date_time_picker_modal.tsx | 8 +- .../datetime_input.test.tsx.snap | 2 +- .../datetime_input/datetime_input.test.tsx | 156 ++++++- .../datetime_input/datetime_input.tsx | 384 ++++++++++++++---- .../dnd_custom_time_picker_modal.tsx | 10 +- webapp/channels/src/i18n/en.json | 1 + .../src/sass/components/_apps-modal.scss | 6 +- .../channels/src/sass/components/_inputs.scss | 46 +++ webapp/channels/src/utils/date_utils.ts | 1 + .../channels/src/utils/dialog_conversion.ts | 8 +- webapp/channels/src/utils/timezone.ts | 14 +- webapp/platform/types/src/apps.ts | 12 +- webapp/platform/types/src/integrations.ts | 9 + 22 files changed, 845 insertions(+), 121 deletions(-) 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 62b78abfa3f..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 @@ -378,4 +378,163 @@ describe('Interactive Dialog - Date and DateTime Fields', () => { 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_component.tsx b/webapp/channels/src/components/apps_form/apps_form_component.tsx index fdbc4648d77..e4a69193be8 100644 --- a/webapp/channels/src/components/apps_form/apps_form_component.tsx +++ b/webapp/channels/src/components/apps_form/apps_form_component.tsx @@ -59,6 +59,7 @@ export type State = { loading: boolean; submitting: string | null; form: AppForm; + isInteracting: boolean; } // Helper function to validate date format and warn if datetime format is used @@ -239,6 +240,7 @@ export class AppsForm extends React.PureComponent { fieldErrors: {}, submitting: null, form, + isInteracting: false, }; } @@ -517,6 +519,10 @@ export class AppsForm extends React.PureComponent { this.setState({values}); }; + setIsInteracting = (isInteracting: boolean) => { + this.setState({isInteracting}); + }; + hasDateTimeFields = (): boolean => { const {fields} = this.props.form; return fields ? fields.some((field) => @@ -530,9 +536,10 @@ export class AppsForm extends React.PureComponent { const bodyClass = loading ? 'apps-form-modal-body-loading' : 'apps-form-modal-body-loaded'; const bodyClassNames = 'apps-form-modal-body-common ' + bodyClass; - // Apply same pattern as DND modal for date/datetime fields + // Apply modal-overflow only when date/time picker is open to allow calendar to escape modal bounds + // while preserving scroll containment when picker is closed + const dialogClassName = this.state.isInteracting ? 'a11y__modal about-modal modal-overflow' : 'a11y__modal about-modal'; const hasDateTimeFields = this.hasDateTimeFields(); - const dialogClassName = hasDateTimeFields ? 'a11y__modal about-modal modal-overflow' : 'a11y__modal about-modal'; return ( { value={this.state.values[field.name]} performLookup={this.performLookup} onChange={this.onChange} + setIsInteracting={this.setIsInteracting} listComponent={isEmbedded ? SuggestionList : ModalSuggestionList} /> ); 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 4fe8c68aa3e..a84ab1e284e 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 @@ -14,12 +14,14 @@ type Props = { field: AppField; value: string | null; onChange: (name: string, value: string | null) => void; + setIsInteracting?: (isInteracting: boolean) => void; }; const AppsFormDateField: React.FC = ({ field, value, onChange, + setIsInteracting, }) => { const intl = useIntl(); const [isPopperOpen, setIsPopperOpen] = useState(false); @@ -53,7 +55,8 @@ const AppsFormDateField: React.FC = ({ const handlePopperOpenState = useCallback((isOpen: boolean) => { setIsPopperOpen(isOpen); - }, []); + setIsInteracting?.(isOpen); + }, [setIsInteracting]); const disabledDays = useMemo(() => { const disabled = []; 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 88a9e58002e..8532752b08c 100644 --- a/webapp/channels/src/components/apps_form/apps_form_datetime_field/apps_form_datetime_field.tsx +++ b/webapp/channels/src/components/apps_form/apps_form_datetime_field/apps_form_datetime_field.tsx @@ -9,7 +9,7 @@ import type {AppField} from '@mattermost/types/apps'; import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; -import DateTimeInput, {getRoundedTime} from 'components/datetime_input/datetime_input'; +import DateTimeInput from 'components/datetime_input/datetime_input'; import {stringToMoment, momentToString, resolveRelativeDate} from 'utils/date_utils'; @@ -20,37 +20,63 @@ type Props = { field: AppField; value: string | null; onChange: (name: string, value: string | null) => void; + setIsInteracting?: (isInteracting: boolean) => 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, + setIsInteracting, }) => { - const timezone = useSelector(getCurrentTimezone); + const userTimezone = useSelector(getCurrentTimezone); - const timePickerInterval = field.time_interval || DEFAULT_TIME_INTERVAL_MINUTES; + // 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(() => { - let result; - if (value) { const parsed = stringToMoment(value, timezone); if (parsed) { - result = parsed; + return parsed; } } - if (!result) { - // Default to current time for display only - result = 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 | null) => { + if (!date) { + onChange(field.name, null); + return; } - - // Round to interval boundary to match dropdown options - return getRoundedTime(result, timePickerInterval); - }, [value, timezone, timePickerInterval]); - - const handleDateTimeChange = useCallback((date: moment.Moment) => { const newValue = momentToString(date, true); onChange(field.name, newValue); }, [field.name, onChange]); @@ -69,13 +95,20 @@ const AppsFormDateTimeField: React.FC = ({ return (
+ {showTimezoneIndicator && ( +
+ {'🌍 Times in ' + getTimezoneAbbreviation(timezone)} +
+ )}
); diff --git a/webapp/channels/src/components/apps_form/apps_form_field/apps_form_field.tsx b/webapp/channels/src/components/apps_form/apps_form_field/apps_form_field.tsx index eedc5d1b2e0..dfb67eaf7be 100644 --- a/webapp/channels/src/components/apps_form/apps_form_field/apps_form_field.tsx +++ b/webapp/channels/src/components/apps_form/apps_form_field/apps_form_field.tsx @@ -35,6 +35,7 @@ export interface Props { value: AppFormValue; onChange: (name: string, value: any) => void; + setIsInteracting?: (isInteracting: boolean) => void; autoFocus?: boolean; listComponent?: React.ComponentProps['listComponent']; performLookup: (name: string, userInput: string) => Promise; @@ -206,6 +207,7 @@ export default class AppsFormField extends React.PureComponent { field={field} value={value as string | null} onChange={onChange} + setIsInteracting={this.props.setIsInteracting} /> {helpTextContent && (
@@ -227,6 +229,7 @@ export default class AppsFormField extends React.PureComponent { field={field} value={value as string | null} onChange={onChange} + setIsInteracting={this.props.setIsInteracting} /> {helpTextContent && (
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 a56a7961538..9b2f22f93c9 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 @@ -64,7 +64,7 @@ exports[`components/datetime_input/DateTimeInput should match snapshot 1`] = ` class="date-time-input__value" > 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 08414ce5609..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', () => ({ @@ -271,4 +271,158 @@ describe('components/datetime_input/DateTimeInput', () => { 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 77148fdacec..9b41df3e486 100644 --- a/webapp/channels/src/components/datetime_input/datetime_input.tsx +++ b/webapp/channels/src/components/datetime_input/datetime_input.tsx @@ -13,7 +13,6 @@ 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'; @@ -33,13 +32,13 @@ export function getRoundedTime(value: Moment, roundedTo = CUSTOM_STATUS_TIME_PIC return moment(value).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); @@ -52,14 +51,170 @@ 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 = ({ @@ -70,10 +225,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 isMilitaryTime = useSelector(isUseMilitaryTime); - const [timeOptions, setTimeOptions] = useState([]); + const [timeOptions, setTimeOptions] = useState([]); const [isPopperOpen, setIsPopperOpen] = useState(false); const [isTimeMenuOpen, setIsTimeMenuOpen] = useState(false); const [menuWidth, setMenuWidth] = useState('200px'); @@ -99,9 +257,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 @@ -120,45 +279,94 @@ 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(() => { - const currentTime = getCurrentMomentForTimezone(timezone); - let startTime = moment(time).startOf('day'); + 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 = () => { + // 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)); - }, [time, timezone, allowPastDates, timePickerInterval]); + }; + + 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 => { if (relativeDate) { return relativeFormatDate(date, formatMessage); } - // Use centralized date formatting utility - return formatDateForDisplay(date.toDate(), locale); + // Extract date in the moment's timezone, not UTC + // .toDate() would convert to UTC and lose the timezone-specific date + const dateInTimezone = new Date(date.year(), date.month(), date.date()); + return formatDateForDisplay(dateInTimezone, locale); }; const calendarIcon = ( @@ -172,12 +380,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, }; @@ -194,69 +400,77 @@ 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 ? ( + + ) : ( + {'--:--'} + )} + + + ), + }} + 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 1b0c57561f9..a0b24da174a 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4246,6 +4246,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/_apps-modal.scss b/webapp/channels/src/sass/components/_apps-modal.scss index 10eb96226d4..3baebcbe36d 100644 --- a/webapp/channels/src/sass/components/_apps-modal.scss +++ b/webapp/channels/src/sass/components/_apps-modal.scss @@ -10,15 +10,15 @@ // This needs to be global like the existing modal-overflow class .modal-overflow { overflow: visible !important; - + .modal-dialog { overflow: visible !important; } - + .modal-content { overflow: visible !important; } - + .modal-body { overflow: visible !important; } diff --git a/webapp/channels/src/sass/components/_inputs.scss b/webapp/channels/src/sass/components/_inputs.scss index 47cf099971b..cc833c836bb 100644 --- a/webapp/channels/src/sass/components/_inputs.scss +++ b/webapp/channels/src/sass/components/_inputs.scss @@ -223,3 +223,49 @@ input::-webkit-file-upload-button { max-height: 300px; overflow-y: auto; } + +// Ensure "(optional)" text in date/datetime field labels is not bold +.form-group { + .control-label { + .light { + font-weight: normal; + } + } +} + +// 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.ts b/webapp/channels/src/utils/date_utils.ts index 4f8863371bc..5b8e7a7e05f 100644 --- a/webapp/channels/src/utils/date_utils.ts +++ b/webapp/channels/src/utils/date_utils.ts @@ -79,6 +79,7 @@ export function momentToString(momentValue: Moment | null, isDateTime: boolean): } if (isDateTime) { + // Clone to avoid mutating the original moment when converting to UTC return momentValue.clone().utc().format(MOMENT_DATETIME_FORMAT); } 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/channels/src/utils/timezone.ts b/webapp/channels/src/utils/timezone.ts index d7b03ad3c24..06785caf2c4 100644 --- a/webapp/channels/src/utils/timezone.ts +++ b/webapp/channels/src/utils/timezone.ts @@ -47,6 +47,18 @@ export function parseDateInTimezone(value: string, timezone?: string): Moment | return parsed.isValid() ? parsed : null; } - const parsed = moment.tz(value, timezone); + // Detect date-only strings (YYYY-MM-DD format, no time component) + const isDateOnly = (/^\d{4}-\d{2}-\d{2}$/).test(value); + + if (isDateOnly) { + // For date-only strings, parse AS IF in the target timezone + // '2025-01-15' in EST should be Jan 15 in EST, not converted from UTC + const parsed = moment.tz(value, timezone); + return parsed.isValid() ? parsed : null; + } + + // For datetime strings (with time/UTC indicator), parse as UTC then convert + // '2025-01-15T14:30:00Z' is absolute UTC time, convert to target timezone + const parsed = moment.utc(value).tz(timezone); return parsed.isValid() ? parsed : null; } 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;