Interactive Dialog - DateTime manual entry and timezone support (#34932)
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Scott Bishel 2026-02-16 13:32:31 -07:00 committed by GitHub
parent ad39398eee
commit 53aa05d8c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 845 additions and 121 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Props, State> {
fieldErrors: {},
submitting: null,
form,
isInteracting: false,
};
}
@ -517,6 +519,10 @@ export class AppsForm extends React.PureComponent<Props, State> {
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<Props, State> {
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 (
<Modal
@ -654,6 +661,7 @@ export class AppsForm extends React.PureComponent<Props, State> {
value={this.state.values[field.name]}
performLookup={this.performLookup}
onChange={this.onChange}
setIsInteracting={this.setIsInteracting}
listComponent={isEmbedded ? SuggestionList : ModalSuggestionList}
/>
);

View file

@ -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<Props> = ({
field,
value,
onChange,
setIsInteracting,
}) => {
const intl = useIntl();
const [isPopperOpen, setIsPopperOpen] = useState(false);
@ -53,7 +55,8 @@ const AppsFormDateField: React.FC<Props> = ({
const handlePopperOpenState = useCallback((isOpen: boolean) => {
setIsPopperOpen(isOpen);
}, []);
setIsInteracting?.(isOpen);
}, [setIsInteracting]);
const disabledDays = useMemo(() => {
const disabled = [];

View file

@ -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<Props> = ({
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<Props> = ({
return (
<div className='apps-form-datetime-input'>
{showTimezoneIndicator && (
<div style={{fontSize: '11px', color: '#888', marginBottom: '8px'}}>
{'🌍 Times in ' + getTimezoneAbbreviation(timezone)}
</div>
)}
<DateTimeInput
time={momentValue}
handleChange={handleDateTimeChange}
timezone={timezone}
relativeDate={true}
relativeDate={!locationTimezone}
timePickerInterval={timePickerInterval}
allowPastDates={allowPastDates}
allowManualTimeEntry={allowManualTimeEntry}
setIsInteracting={setIsInteracting}
/>
</div>
);

View file

@ -35,6 +35,7 @@ export interface Props {
value: AppFormValue;
onChange: (name: string, value: any) => void;
setIsInteracting?: (isInteracting: boolean) => void;
autoFocus?: boolean;
listComponent?: React.ComponentProps<typeof AutocompleteSelector>['listComponent'];
performLookup: (name: string, userInput: string) => Promise<AppSelectOption[]>;
@ -206,6 +207,7 @@ export default class AppsFormField extends React.PureComponent<Props> {
field={field}
value={value as string | null}
onChange={onChange}
setIsInteracting={this.props.setIsInteracting}
/>
{helpTextContent && (
<div className='help-text'>
@ -227,6 +229,7 @@ export default class AppsFormField extends React.PureComponent<Props> {
field={field}
value={value as string | null}
onChange={onChange}
setIsInteracting={this.props.setIsInteracting}
/>
{helpTextContent && (
<div className='help-text'>

View file

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

View file

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

View file

@ -64,7 +64,7 @@ exports[`components/datetime_input/DateTimeInput should match snapshot 1`] = `
class="date-time-input__value"
>
<time
datetime="2025-06-08T12:09:00.000"
datetime="2025-06-08T12:09:00.000Z"
>
12:09 PM
</time>

View file

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

View file

@ -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<TimeInputManualProps> = ({
time,
timezone,
isMilitaryTime,
onTimeChange,
}) => {
const {formatMessage} = useIntl();
const [timeInputValue, setTimeInputValue] = useState<string>('');
const [timeInputError, setTimeInputError] = useState<boolean>(false);
const timeInputRef = useRef<HTMLInputElement>(null);
// Sync input value with time prop changes
useEffect(() => {
if (time) {
const formatted = time.format(isMilitaryTime ? 'HH:mm' : 'h:mm A');
setTimeInputValue(formatted);
} else {
setTimeInputValue('');
}
}, [time, isMilitaryTime]);
const handleTimeInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setTimeInputValue(event.target.value);
setTimeInputError(false); // Clear error as user types
}, []);
const handleTimeInputBlur = useCallback(() => {
const parsed = parseTimeString(timeInputValue);
if (!parsed) {
if (timeInputValue.trim() !== '') {
setTimeInputError(true);
}
return;
}
// Create a moment with the parsed time on the current date (no rounding)
const baseMoment = time ? time.clone() : getCurrentMomentForTimezone(timezone);
let targetMoment: Moment;
if (timezone) {
targetMoment = moment.tz([
baseMoment.year(),
baseMoment.month(),
baseMoment.date(),
parsed.hours,
parsed.minutes,
0,
0,
], timezone);
} else {
baseMoment.hour(parsed.hours);
baseMoment.minute(parsed.minutes);
baseMoment.second(0);
baseMoment.millisecond(0);
targetMoment = baseMoment;
}
// Valid time - update (no auto-advance, no exclusion checking)
onTimeChange(targetMoment);
setTimeInputError(false);
}, [timeInputValue, time, timezone, onTimeChange]);
const handleTimeInputKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (isKeyPressed(event as any, Constants.KeyCodes.ENTER)) {
event.preventDefault();
timeInputRef.current?.blur(); // Trigger validation
}
}, []);
return (
<div className='date-time-input-manual'>
<input
ref={timeInputRef}
id='time_input'
type='text'
className={`date-time-input__text-input${timeInputError ? ' error' : ''}`}
value={timeInputValue}
onChange={handleTimeInputChange}
onBlur={handleTimeInputBlur}
onKeyDown={handleTimeInputKeyDown}
placeholder={isMilitaryTime ? '13:40' : '1:40 PM'}
aria-label={formatMessage({
id: 'datetime.time',
defaultMessage: 'Time',
})}
/>
</div>
);
};
type Props = {
time: Moment;
handleChange: (date: Moment) => void;
time: Moment | null;
handleChange: (date: Moment | null) => void;
timezone?: string;
setIsInteracting?: (interacting: boolean) => void;
relativeDate?: boolean;
timePickerInterval?: number;
allowPastDates?: boolean;
allowManualTimeEntry?: boolean;
}
const DateTimeInputContainer: React.FC<Props> = ({
@ -70,10 +225,13 @@ const DateTimeInputContainer: React.FC<Props> = ({
relativeDate,
timePickerInterval,
allowPastDates = false,
allowManualTimeEntry = false,
}: Props) => {
const currentTime = getCurrentMomentForTimezone(timezone);
const displayTime = time; // No automatic default - field stays null until user selects
const locale = useSelector(getCurrentLocale);
const isMilitaryTime = useSelector(isUseMilitaryTime);
const [timeOptions, setTimeOptions] = useState<Date[]>([]);
const [timeOptions, setTimeOptions] = useState<Moment[]>([]);
const [isPopperOpen, setIsPopperOpen] = useState(false);
const [isTimeMenuOpen, setIsTimeMenuOpen] = useState(false);
const [menuWidth, setMenuWidth] = useState<string>('200px');
@ -99,9 +257,10 @@ const DateTimeInputContainer: React.FC<Props> = ({
}
}, [setIsInteracting]);
const handleTimeChange = useCallback((time: Date) => {
handleChange(timezone ? moment.tz(time, timezone) : moment(time));
}, [handleChange, timezone]);
const handleTimeChange = useCallback((selectedTime: Moment) => {
// selectedTime is already a Moment with correct timezone from getTimeInIntervals
handleChange(selectedTime.clone().second(0).millisecond(0));
}, [handleChange]);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// Handle escape key for date picker when time menu is not open
@ -120,45 +279,94 @@ const DateTimeInputContainer: React.FC<Props> = ({
};
}, [handleKeyDown]);
// Auto-round time if it's not already on an interval boundary
// This ensures consistent behavior across all callers (DND, Custom Status, Post Reminder, etc.)
// Uses default 30-minute interval if not specified
// Skip for manual entry fields (user types exact minutes)
useEffect(() => {
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<Props> = ({
const datePickerProps: DayPickerProps = {
initialFocus: isPopperOpen,
mode: 'single',
selected: time.toDate(),
defaultMonth: time.toDate(),
selected: displayTime?.toDate(),
defaultMonth: displayTime?.toDate() || new Date(),
onDayClick: handleDayChange,
disabled: allowPastDates ? undefined : [{
before: currentTime,
}],
disabled: allowPastDates ? undefined : {before: currentTime.toDate()},
showOutsideDays: true,
};
@ -194,69 +400,77 @@ const DateTimeInputContainer: React.FC<Props> = ({
defaultMessage: 'Date',
})}
icon={calendarIcon}
value={formatDate(time)}
value={displayTime ? formatDate(displayTime) : ''}
>
<></>
<span className='date-time-input__placeholder'>
{formatMessage({
id: 'datetime.select_date',
defaultMessage: 'Select date',
})}
</span>
</DatePicker>
</div>
<div
className='dateTime__time'
ref={timeContainerRef}
>
<Menu.Container
menuButton={{
id: 'time_button',
dataTestId: 'time_button',
'aria-label': formatMessage({
id: 'datetime.time',
defaultMessage: 'Time',
}),
class: isTimeMenuOpen ? 'date-time-input date-time-input--open' : 'date-time-input',
children: (
<>
<span className='date-time-input__label'>{formatMessage({
id: 'datetime.time',
defaultMessage: 'Time',
})}</span>
<span className='date-time-input__icon'>{clockIcon}</span>
<span className='date-time-input__value'>
<Timestamp
useRelative={false}
useDate={false}
useTime={isMilitaryTime ? {hour: 'numeric', minute: '2-digit', hourCycle: 'h23'} : {hour: 'numeric', minute: '2-digit', hour12: true}}
value={time.toString()}
/>
</span>
</>
),
}}
menu={{
id: 'expiryTimeMenu',
'aria-label': formatMessage({id: 'time_dropdown.choose_time', defaultMessage: 'Choose a time'}),
onToggle: handleTimeMenuToggle,
width: menuWidth,
className: 'time-menu-scrollable',
}}
>
{timeOptions.map((option, index) => (
<Menu.Item
key={option.getTime()}
id={`time_option_${index}`}
data-testid={`time_option_${index}`}
labels={
<span>
<Timestamp
useRelative={false}
useDate={false}
useTime={isMilitaryTime ? {hour: 'numeric', minute: '2-digit', hourCycle: 'h23'} : {hour: 'numeric', minute: '2-digit', hour12: true}}
value={option}
/>
</span>
}
onClick={() => handleTimeChange(option)}
/>
))}
</Menu.Container>
{allowManualTimeEntry ? (
<TimeInputManual
time={displayTime}
timezone={timezone}
isMilitaryTime={isMilitaryTime}
onTimeChange={handleTimeChange}
/>
) : (
<Menu.Container
menuButton={{
id: 'time_button',
dataTestId: 'time_button',
'aria-label': formatMessage({
id: 'datetime.time',
defaultMessage: 'Time',
}),
class: isTimeMenuOpen ? 'date-time-input date-time-input--open' : 'date-time-input',
children: (
<>
<span className='date-time-input__label'>{formatMessage({
id: 'datetime.time',
defaultMessage: 'Time',
})}</span>
<span className='date-time-input__icon'>{clockIcon}</span>
<span className='date-time-input__value'>
{displayTime ? (
<time dateTime={displayTime.toISOString()}>
{displayTime.format(isMilitaryTime ? 'HH:mm' : 'LT')}
</time>
) : (
<span>{'--:--'}</span>
)}
</span>
</>
),
}}
menu={{
id: 'expiryTimeMenu',
'aria-label': formatMessage({id: 'time_dropdown.choose_time', defaultMessage: 'Choose a time'}),
onToggle: handleTimeMenuToggle,
width: menuWidth,
className: 'time-menu-scrollable',
}}
>
{timeOptions.map((option, index) => (
<Menu.Item
key={index}
id={`time_option_${index}`}
data-testid={`time_option_${index}`}
labels={
<span>{option.format(isMilitaryTime ? 'HH:mm' : 'LT')}</span>
}
onClick={() => handleTimeChange(option)}
/>
))}
</Menu.Container>
)}
</div>
</div>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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