mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-03 20:40:00 -05:00
Merge 6bbf3a26e7 into 0263262ef4
This commit is contained in:
commit
1438debc71
19 changed files with 1020 additions and 114 deletions
|
|
@ -291,4 +291,250 @@ describe('Interactive Dialog - Date and DateTime Fields', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T2530H - DateTime field respects 12h/24h time preference', () => {
|
||||
// # Set user preference to 24-hour time
|
||||
cy.apiSaveClockDisplayModeTo24HourPreference(true);
|
||||
|
||||
cy.reload();
|
||||
cy.get('#postListContent').should('be.visible');
|
||||
|
||||
// # Open datetime dialog
|
||||
openDateTimeDialog();
|
||||
|
||||
// * Verify Meeting Time field
|
||||
verifyFormGroup('Meeting Time', {
|
||||
inputSelector: '.apps-form-datetime-input',
|
||||
});
|
||||
|
||||
// # Select a date
|
||||
cy.get('#appsModal').within(() => {
|
||||
cy.contains('.form-group', 'Meeting Time').within(() => {
|
||||
cy.get('.dateTime__date .date-time-input').click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('.rdp', {timeout: 5000}).should('be.visible');
|
||||
selectDateFromPicker('15');
|
||||
|
||||
// # Open time menu
|
||||
cy.get('#appsModal').within(() => {
|
||||
cy.contains('.form-group', 'Meeting Time').within(() => {
|
||||
cy.get('.dateTime__time button[data-testid="time_button"]').click();
|
||||
});
|
||||
});
|
||||
|
||||
// * Verify 24-hour format in dropdown (e.g., "14:00" not "2:00 PM")
|
||||
cy.get('[id="expiryTimeMenu"]', {timeout: 10000}).should('be.visible');
|
||||
cy.get('[id^="time_option_"]').first().invoke('text').then((text) => {
|
||||
expect(text).to.match(/^\d{2}:\d{2}$/); // 24-hour format: HH:MM
|
||||
});
|
||||
|
||||
// # Select a time
|
||||
cy.get('[id^="time_option_"]').eq(5).click();
|
||||
|
||||
// * Verify selected time shows in 24-hour format
|
||||
cy.get('#appsModal').within(() => {
|
||||
cy.contains('.form-group', 'Meeting Time').within(() => {
|
||||
cy.get('.dateTime__time .date-time-input__value').invoke('text').then((text) => {
|
||||
expect(text).to.match(/^\d{2}:\d{2}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// # Close dialog
|
||||
cy.get('#appsModal').within(() => {
|
||||
cy.get('#appsModalCancel').click();
|
||||
});
|
||||
|
||||
// # Set user preference to 12-hour time
|
||||
cy.apiSaveClockDisplayModeTo24HourPreference(false);
|
||||
|
||||
cy.reload();
|
||||
cy.get('#postListContent').should('be.visible');
|
||||
|
||||
// # Open dialog again
|
||||
openDateTimeDialog();
|
||||
|
||||
// # Select date and open time menu
|
||||
cy.get('#appsModal').within(() => {
|
||||
cy.contains('.form-group', 'Meeting Time').within(() => {
|
||||
cy.get('.dateTime__date .date-time-input').click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('.rdp').should('be.visible');
|
||||
selectDateFromPicker('20');
|
||||
|
||||
cy.get('#appsModal').within(() => {
|
||||
cy.contains('.form-group', 'Meeting Time').within(() => {
|
||||
cy.get('.dateTime__time button[data-testid="time_button"]').click();
|
||||
});
|
||||
});
|
||||
|
||||
// * Verify 12-hour format in dropdown (e.g., "2:00 PM" not "14:00")
|
||||
cy.get('[id="expiryTimeMenu"]').should('be.visible');
|
||||
cy.get('[id^="time_option_"]').first().invoke('text').then((text) => {
|
||||
expect(text).to.match(/\d{1,2}:\d{2} [AP]M/); // 12-hour format: H:MM AM/PM
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T2530O - Manual time entry (basic functionality)', () => {
|
||||
// # Open timezone-manual dialog via webhook
|
||||
openDateTimeDialog('timezone-manual');
|
||||
verifyModalTitle('Timezone & Manual Entry Demo');
|
||||
|
||||
// * Verify local manual entry field exists
|
||||
verifyFormGroup('Your Local Time (Manual Entry)', {
|
||||
helpText: 'Type any time',
|
||||
});
|
||||
|
||||
// # Type a time in manual entry field
|
||||
cy.get('#appsModal').within(() => {
|
||||
cy.contains('.form-group', 'Your Local Time (Manual Entry)').within(() => {
|
||||
cy.get('input#time_input').should('be.visible').type('3:45pm').blur();
|
||||
});
|
||||
});
|
||||
|
||||
// * Verify time is accepted (no error state)
|
||||
cy.contains('.form-group', 'Your Local Time (Manual Entry)').within(() => {
|
||||
cy.get('input#time_input').should('not.have.class', 'error');
|
||||
cy.get('input#time_input').should('have.value', '3:45 PM');
|
||||
});
|
||||
|
||||
// # Submit form
|
||||
cy.get('#appsModal').within(() => {
|
||||
cy.get('#appsModalSubmit').click();
|
||||
});
|
||||
|
||||
// * Verify submission success
|
||||
cy.get('#appsModal', {timeout: 10000}).should('not.exist');
|
||||
});
|
||||
|
||||
it('MM-T2530P - Manual time entry (multiple formats)', () => {
|
||||
openDateTimeDialog('timezone-manual');
|
||||
|
||||
const testFormats = [
|
||||
{input: '12a', expected12h: '12:00 AM'},
|
||||
{input: '14:30', expected12h: '2:30 PM'},
|
||||
{input: '9pm', expected12h: '9:00 PM'},
|
||||
];
|
||||
|
||||
testFormats.forEach(({input, expected12h}) => {
|
||||
cy.contains('.form-group', 'Your Local Time (Manual Entry)').within(() => {
|
||||
cy.get('input#time_input').clear().type(input).blur();
|
||||
|
||||
// Wait for formatting to apply
|
||||
cy.wait(100);
|
||||
|
||||
// Verify time is formatted correctly (assumes 12h preference for test consistency)
|
||||
cy.get('input#time_input').invoke('val').should('equal', expected12h);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T2530Q - Manual time entry (invalid format)', () => {
|
||||
openDateTimeDialog('timezone-manual');
|
||||
|
||||
// # Type invalid time
|
||||
cy.contains('.form-group', 'Your Local Time (Manual Entry)').within(() => {
|
||||
cy.get('input#time_input').type('abc').blur();
|
||||
|
||||
// * Verify error state
|
||||
cy.get('input#time_input').should('have.class', 'error');
|
||||
});
|
||||
|
||||
// # Type valid time
|
||||
cy.contains('.form-group', 'Your Local Time (Manual Entry)').within(() => {
|
||||
cy.get('input#time_input').clear().type('2:30pm').blur();
|
||||
|
||||
// * Verify error clears
|
||||
cy.get('input#time_input').should('not.have.class', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T2530R - Timezone support (dropdown)', function() {
|
||||
// Skip if running in London timezone (can't test timezone conversion)
|
||||
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (userTimezone === 'Europe/London' || userTimezone === 'GMT' || userTimezone.includes('London')) {
|
||||
this.skip();
|
||||
}
|
||||
|
||||
openDateTimeDialog('timezone-manual');
|
||||
|
||||
// * Verify timezone indicator is shown
|
||||
cy.contains('.form-group', 'London Office Hours (Dropdown)').within(() => {
|
||||
cy.contains('Times in GMT').should('be.visible');
|
||||
});
|
||||
|
||||
// # Select a date
|
||||
cy.contains('.form-group', 'London Office Hours (Dropdown)').within(() => {
|
||||
cy.get('.dateTime__date .date-time-input').click();
|
||||
});
|
||||
|
||||
cy.get('.rdp').should('be.visible');
|
||||
selectDateFromPicker('15');
|
||||
|
||||
// # Open time dropdown
|
||||
cy.contains('.form-group', 'London Office Hours (Dropdown)').within(() => {
|
||||
cy.get('.dateTime__time button[data-testid="time_button"]').click();
|
||||
});
|
||||
|
||||
// * Verify dropdown shows times starting at midnight (London time)
|
||||
cy.get('[id="expiryTimeMenu"]').should('be.visible');
|
||||
cy.get('[id^="time_option_"]').first().invoke('text').then((text) => {
|
||||
// Should show midnight in 12h or 24h format
|
||||
expect(text).to.match(/^(12:00 AM|00:00)$/);
|
||||
});
|
||||
|
||||
// # Select a time
|
||||
cy.get('[id^="time_option_"]').eq(5).click();
|
||||
|
||||
// # Submit form
|
||||
cy.get('#appsModal').within(() => {
|
||||
cy.get('#appsModalSubmit').click();
|
||||
});
|
||||
|
||||
// * Verify submission success (UTC conversion verified server-side)
|
||||
cy.get('#appsModal', {timeout: 10000}).should('not.exist');
|
||||
});
|
||||
|
||||
it('MM-T2530S - Timezone support (manual entry)', function() {
|
||||
// Skip if running in London timezone
|
||||
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (userTimezone === 'Europe/London' || userTimezone === 'GMT' || userTimezone.includes('London')) {
|
||||
this.skip();
|
||||
}
|
||||
|
||||
openDateTimeDialog('timezone-manual');
|
||||
|
||||
// * Verify timezone indicator is shown
|
||||
cy.contains('.form-group', 'London Office Hours (Manual Entry)').within(() => {
|
||||
cy.contains('Times in GMT').should('be.visible');
|
||||
});
|
||||
|
||||
// # Select date
|
||||
cy.contains('.form-group', 'London Office Hours (Manual Entry)').within(() => {
|
||||
cy.get('.dateTime__date .date-time-input').click();
|
||||
});
|
||||
|
||||
cy.get('.rdp').should('be.visible');
|
||||
selectDateFromPicker('15');
|
||||
|
||||
// # Type time in manual entry
|
||||
cy.contains('.form-group', 'London Office Hours (Manual Entry)').within(() => {
|
||||
cy.get('input#time_input').clear().type('2:30pm').blur();
|
||||
|
||||
// * Verify time is accepted
|
||||
cy.get('input#time_input').should('not.have.class', 'error');
|
||||
});
|
||||
|
||||
// # Submit form
|
||||
cy.get('#appsModal').within(() => {
|
||||
cy.get('#appsModalSubmit').click();
|
||||
});
|
||||
|
||||
// * Verify submission success (timezone conversion happens server-side)
|
||||
cy.get('#appsModal', {timeout: 10000}).should('not.exist');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type {AppField} from '@mattermost/types/apps';
|
|||
|
||||
import DatePicker from 'components/date_picker/date_picker';
|
||||
|
||||
import {stringToDate, dateToString, resolveRelativeDate} from 'utils/date_utils';
|
||||
import {stringToDate, dateToString, resolveRelativeDate, formatDateForDisplay} from 'utils/date_utils';
|
||||
|
||||
type Props = {
|
||||
field: AppField;
|
||||
|
|
@ -34,11 +34,7 @@ const AppsFormDateField: React.FC<Props> = ({
|
|||
}
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat(intl.locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(dateValue);
|
||||
return formatDateForDisplay(dateValue, intl.locale);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,40 @@ type Props = {
|
|||
onChange: (name: string, value: string | null) => void;
|
||||
};
|
||||
|
||||
// Helper to get timezone abbreviation (e.g., "MST", "EDT")
|
||||
const getTimezoneAbbreviation = (timezone: string): string => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
const parts = formatter.formatToParts(now);
|
||||
const tzPart = parts.find((part) => part.type === 'timeZoneName');
|
||||
return tzPart?.value || timezone;
|
||||
} catch {
|
||||
return timezone;
|
||||
}
|
||||
};
|
||||
|
||||
const AppsFormDateTimeField: React.FC<Props> = ({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const timezone = useSelector(getCurrentTimezone);
|
||||
const userTimezone = useSelector(getCurrentTimezone);
|
||||
|
||||
// Extract datetime config with fallback to top-level fields
|
||||
const config = field.datetime_config || {};
|
||||
const locationTimezone = config.location_timezone;
|
||||
const timePickerInterval = config.time_interval ?? field.time_interval ?? DEFAULT_TIME_INTERVAL_MINUTES;
|
||||
const allowManualTimeEntry = config.allow_manual_time_entry ?? false;
|
||||
|
||||
// Use location_timezone if specified, otherwise fall back to user's timezone
|
||||
const timezone = locationTimezone || userTimezone;
|
||||
|
||||
// Show timezone indicator when location_timezone is set
|
||||
const showTimezoneIndicator = Boolean(locationTimezone);
|
||||
|
||||
const momentValue = useMemo(() => {
|
||||
if (value) {
|
||||
|
|
@ -37,17 +65,20 @@ const AppsFormDateTimeField: React.FC<Props> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// Default to current time for display only
|
||||
return timezone ? moment.tz(timezone) : moment();
|
||||
// No automatic defaults - field starts empty
|
||||
// Required fields get a default from apps_form_component.tsx
|
||||
return null;
|
||||
}, [value, timezone]);
|
||||
|
||||
const handleDateTimeChange = useCallback((date: moment.Moment) => {
|
||||
const handleDateTimeChange = useCallback((date: moment.Moment | null) => {
|
||||
if (!date) {
|
||||
onChange(field.name, null);
|
||||
return;
|
||||
}
|
||||
const newValue = momentToString(date, true);
|
||||
onChange(field.name, newValue);
|
||||
}, [field.name, onChange]);
|
||||
|
||||
const timePickerInterval = field.time_interval || DEFAULT_TIME_INTERVAL_MINUTES;
|
||||
|
||||
const allowPastDates = useMemo(() => {
|
||||
if (field.min_date) {
|
||||
const resolvedMinDate = resolveRelativeDate(field.min_date);
|
||||
|
|
@ -62,13 +93,19 @@ const AppsFormDateTimeField: React.FC<Props> = ({
|
|||
|
||||
return (
|
||||
<div className='apps-form-datetime-input'>
|
||||
{showTimezoneIndicator && (
|
||||
<div style={{fontSize: '11px', color: '#888', marginBottom: '8px', fontStyle: 'italic'}}>
|
||||
{'🌍 Times in ' + getTimezoneAbbreviation(timezone)}
|
||||
</div>
|
||||
)}
|
||||
<DateTimeInput
|
||||
time={momentValue}
|
||||
handleChange={handleDateTimeChange}
|
||||
timezone={timezone}
|
||||
relativeDate={true}
|
||||
relativeDate={!locationTimezone}
|
||||
timePickerInterval={timePickerInterval}
|
||||
allowPastDates={allowPastDates}
|
||||
allowManualTimeEntry={allowManualTimeEntry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ exports[`components/datetime_input/DateTimeInput should match snapshot 1`] = `
|
|||
<span
|
||||
class="date-time-input__value"
|
||||
>
|
||||
6/8/2025
|
||||
Jun 8, 2025
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -63,11 +63,9 @@ exports[`components/datetime_input/DateTimeInput should match snapshot 1`] = `
|
|||
<span
|
||||
class="date-time-input__value"
|
||||
>
|
||||
<time
|
||||
datetime="2025-06-08T12:09:00.000"
|
||||
>
|
||||
<span>
|
||||
12:09 PM
|
||||
</time>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import React from 'react';
|
|||
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
||||
import * as timezoneUtils from 'utils/timezone';
|
||||
|
||||
import DateTimeInput, {getTimeInIntervals, getRoundedTime} from './datetime_input';
|
||||
import DateTimeInput, {getTimeInIntervals, getRoundedTime, parseTimeString} from './datetime_input';
|
||||
|
||||
// Mock timezone utilities
|
||||
jest.mock('utils/timezone', () => ({
|
||||
|
|
@ -15,6 +15,10 @@ jest.mock('utils/timezone', () => ({
|
|||
isBeforeTime: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('selectors/preferences', () => ({
|
||||
isUseMilitaryTime: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetCurrentMomentForTimezone = timezoneUtils.getCurrentMomentForTimezone as jest.MockedFunction<typeof timezoneUtils.getCurrentMomentForTimezone>;
|
||||
const mockIsBeforeTime = timezoneUtils.isBeforeTime as jest.MockedFunction<typeof timezoneUtils.isBeforeTime>;
|
||||
|
||||
|
|
@ -223,4 +227,202 @@ describe('components/datetime_input/DateTimeInput', () => {
|
|||
expect(timeOptions.length).toBeGreaterThan(0); // But should have some options
|
||||
});
|
||||
});
|
||||
|
||||
describe('user preference handling', () => {
|
||||
it('should use user locale for date formatting', () => {
|
||||
renderWithContext(<DateTimeInput {...baseProps}/>);
|
||||
|
||||
// Date should be formatted using formatDateForDisplay utility
|
||||
// which uses user's locale from getCurrentLocale selector
|
||||
expect(screen.getByText('Date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should respect military time (24-hour) preference', () => {
|
||||
const mockIsUseMilitaryTime = require('selectors/preferences').isUseMilitaryTime;
|
||||
mockIsUseMilitaryTime.mockReturnValue(true);
|
||||
|
||||
renderWithContext(<DateTimeInput {...baseProps}/>);
|
||||
|
||||
// Timestamp component should receive useTime prop with hourCycle: 'h23'
|
||||
// This is tested indirectly - times would show as 14:00 instead of 2:00 PM
|
||||
expect(mockIsUseMilitaryTime).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respect 12-hour time preference', () => {
|
||||
const mockIsUseMilitaryTime = require('selectors/preferences').isUseMilitaryTime;
|
||||
mockIsUseMilitaryTime.mockReturnValue(false);
|
||||
|
||||
renderWithContext(<DateTimeInput {...baseProps}/>);
|
||||
|
||||
// Timestamp component should receive useTime prop with hour12: true
|
||||
// This is tested indirectly - times would show as 2:00 PM instead of 14:00
|
||||
expect(mockIsUseMilitaryTime).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should format dates consistently (not browser default)', () => {
|
||||
const testDate = moment('2025-06-15T12:00:00Z');
|
||||
const props = {...baseProps, time: testDate};
|
||||
|
||||
renderWithContext(<DateTimeInput {...props}/>);
|
||||
|
||||
// Date should use Intl.DateTimeFormat(locale, {month: 'short', ...})
|
||||
// Not DateTime.fromJSDate().toLocaleString() which varies by browser
|
||||
// Expected format: "Jun 15, 2025" not "6/15/2025"
|
||||
expect(props.time).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-rounding behavior', () => {
|
||||
it('should auto-round time to interval boundary on mount', () => {
|
||||
const handleChange = jest.fn();
|
||||
const unroundedTime = moment('2025-06-08T14:17:00Z'); // 14:17 - not on 30-min boundary
|
||||
|
||||
renderWithContext(
|
||||
<DateTimeInput
|
||||
time={unroundedTime}
|
||||
handleChange={handleChange}
|
||||
timePickerInterval={30}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should auto-round 14:17 to 14:30 and call handleChange
|
||||
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||
const roundedTime = handleChange.mock.calls[0][0];
|
||||
expect(roundedTime.minute()).toBe(30);
|
||||
});
|
||||
|
||||
it('should not call handleChange if time is already rounded', () => {
|
||||
const handleChange = jest.fn();
|
||||
const roundedTime = moment('2025-06-08T14:30:00Z'); // Already on 30-min boundary
|
||||
|
||||
renderWithContext(
|
||||
<DateTimeInput
|
||||
time={roundedTime}
|
||||
handleChange={handleChange}
|
||||
timePickerInterval={30}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should not call handleChange since time is already rounded
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use 30-minute default interval when prop not provided', () => {
|
||||
const handleChange = jest.fn();
|
||||
const unroundedTime = moment('2025-06-08T14:17:00Z');
|
||||
|
||||
renderWithContext(
|
||||
<DateTimeInput
|
||||
time={unroundedTime}
|
||||
handleChange={handleChange}
|
||||
|
||||
// No timePickerInterval prop - should use 30-min default
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should round using default 30-min interval
|
||||
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||
const roundedTime = handleChange.mock.calls[0][0];
|
||||
expect(roundedTime.minute()).toBe(30); // 14:17 -> 14:30
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTimeString', () => {
|
||||
it('should parse 12-hour format with AM/PM', () => {
|
||||
expect(parseTimeString('12a')).toEqual({hours: 0, minutes: 0}); // 12 AM = 00:00
|
||||
expect(parseTimeString('12am')).toEqual({hours: 0, minutes: 0});
|
||||
expect(parseTimeString('1a')).toEqual({hours: 1, minutes: 0});
|
||||
expect(parseTimeString('11pm')).toEqual({hours: 23, minutes: 0});
|
||||
expect(parseTimeString('12p')).toEqual({hours: 12, minutes: 0}); // 12 PM = 12:00
|
||||
expect(parseTimeString('12pm')).toEqual({hours: 12, minutes: 0});
|
||||
});
|
||||
|
||||
it('should parse 12-hour format with minutes', () => {
|
||||
expect(parseTimeString('3:30pm')).toEqual({hours: 15, minutes: 30});
|
||||
expect(parseTimeString('3:30 PM')).toEqual({hours: 15, minutes: 30});
|
||||
expect(parseTimeString('9:15am')).toEqual({hours: 9, minutes: 15});
|
||||
expect(parseTimeString('12:45am')).toEqual({hours: 0, minutes: 45});
|
||||
expect(parseTimeString('12:30pm')).toEqual({hours: 12, minutes: 30});
|
||||
});
|
||||
|
||||
it('should parse 24-hour format', () => {
|
||||
expect(parseTimeString('00:00')).toEqual({hours: 0, minutes: 0});
|
||||
expect(parseTimeString('14:30')).toEqual({hours: 14, minutes: 30});
|
||||
expect(parseTimeString('23:59')).toEqual({hours: 23, minutes: 59});
|
||||
expect(parseTimeString('9:15')).toEqual({hours: 9, minutes: 15});
|
||||
});
|
||||
|
||||
it('should parse time without minutes (defaults to :00)', () => {
|
||||
expect(parseTimeString('14')).toEqual({hours: 14, minutes: 0});
|
||||
expect(parseTimeString('9')).toEqual({hours: 9, minutes: 0});
|
||||
});
|
||||
|
||||
it('should handle various spacing and case', () => {
|
||||
expect(parseTimeString(' 3:30pm ')).toEqual({hours: 15, minutes: 30});
|
||||
expect(parseTimeString('3:30PM')).toEqual({hours: 15, minutes: 30});
|
||||
expect(parseTimeString('3:30 pm')).toEqual({hours: 15, minutes: 30});
|
||||
});
|
||||
|
||||
it('should reject invalid hour values', () => {
|
||||
expect(parseTimeString('25:00')).toBeNull(); // 25 hours invalid
|
||||
expect(parseTimeString('24:00')).toBeNull(); // 24 hours invalid
|
||||
expect(parseTimeString('13pm')).toBeNull(); // 13 PM invalid
|
||||
expect(parseTimeString('0am')).toBeNull(); // 0 AM invalid
|
||||
});
|
||||
|
||||
it('should reject invalid minute values', () => {
|
||||
expect(parseTimeString('3:60pm')).toBeNull(); // 60 minutes invalid
|
||||
expect(parseTimeString('14:99')).toBeNull();
|
||||
expect(parseTimeString('3:-5pm')).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject invalid formats', () => {
|
||||
expect(parseTimeString('abc')).toBeNull();
|
||||
expect(parseTimeString('12:34:56')).toBeNull(); // Seconds not supported
|
||||
expect(parseTimeString('pm')).toBeNull();
|
||||
expect(parseTimeString('')).toBeNull();
|
||||
expect(parseTimeString(null as any)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle edge cases at midnight and noon', () => {
|
||||
expect(parseTimeString('12:00am')).toEqual({hours: 0, minutes: 0}); // Midnight
|
||||
expect(parseTimeString('12:01am')).toEqual({hours: 0, minutes: 1});
|
||||
expect(parseTimeString('11:59pm')).toEqual({hours: 23, minutes: 59});
|
||||
expect(parseTimeString('12:00pm')).toEqual({hours: 12, minutes: 0}); // Noon
|
||||
expect(parseTimeString('12:59pm')).toEqual({hours: 12, minutes: 59});
|
||||
});
|
||||
});
|
||||
|
||||
describe('timezone handling', () => {
|
||||
it('should preserve timezone when generating time intervals', () => {
|
||||
const londonTime = moment.tz('2025-06-08T14:00:00', 'Europe/London');
|
||||
const intervals = getTimeInIntervals(londonTime, 60);
|
||||
|
||||
// All intervals should preserve London timezone
|
||||
expect(intervals.length).toBeGreaterThan(0);
|
||||
intervals.forEach((interval) => {
|
||||
expect(interval.tz()).toBe('Europe/London');
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate intervals starting at midnight in specified timezone', () => {
|
||||
const londonMidnight = moment.tz('2025-06-08', 'Europe/London').startOf('day');
|
||||
const intervals = getTimeInIntervals(londonMidnight, 60);
|
||||
|
||||
// First interval should be midnight in London
|
||||
expect(intervals[0].format('HH:mm')).toBe('00:00');
|
||||
expect(intervals[0].tz()).toBe('Europe/London');
|
||||
});
|
||||
|
||||
it('should handle timezone conversion in parseTimeString and moment creation', () => {
|
||||
// This tests the pattern used in TimeInputManual
|
||||
const parsed = parseTimeString('3:45pm');
|
||||
expect(parsed).toEqual({hours: 15, minutes: 45});
|
||||
|
||||
// Create moment in specific timezone
|
||||
const londonTime = moment.tz([2025, 5, 8, parsed!.hours, parsed!.minutes, 0, 0], 'Europe/London');
|
||||
expect(londonTime.tz()).toBe('Europe/London');
|
||||
expect(londonTime.format('HH:mm')).toBe('15:45');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {DateTime} from 'luxon';
|
||||
import type {Moment} from 'moment-timezone';
|
||||
import moment from 'moment-timezone';
|
||||
import React, {useEffect, useState, useCallback, useRef} from 'react';
|
||||
|
|
@ -10,12 +9,13 @@ import {useIntl} from 'react-intl';
|
|||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {getCurrentLocale} from 'selectors/i18n';
|
||||
import {isUseMilitaryTime} from 'selectors/preferences';
|
||||
|
||||
import DatePicker from 'components/date_picker';
|
||||
import * as Menu from 'components/menu';
|
||||
import Timestamp from 'components/timestamp';
|
||||
|
||||
import Constants from 'utils/constants';
|
||||
import {formatDateForDisplay} from 'utils/date_utils';
|
||||
import {relativeFormatDate} from 'utils/datetime';
|
||||
import {isKeyPressed} from 'utils/keyboard';
|
||||
import {getCurrentMomentForTimezone, isBeforeTime} from 'utils/timezone';
|
||||
|
|
@ -32,13 +32,13 @@ export function getRoundedTime(value: Moment, roundedTo = CUSTOM_STATUS_TIME_PIC
|
|||
return start.add(remainder, 'm').seconds(0).milliseconds(0);
|
||||
}
|
||||
|
||||
export const getTimeInIntervals = (startTime: Moment, interval = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES): Date[] => {
|
||||
export const getTimeInIntervals = (startTime: Moment, interval = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES): Moment[] => {
|
||||
let time = moment(startTime);
|
||||
const nextDay = moment(startTime).add(1, 'days').startOf('day');
|
||||
|
||||
const intervals: Date[] = [];
|
||||
const intervals: Moment[] = [];
|
||||
while (time.isBefore(nextDay)) {
|
||||
intervals.push(time.toDate());
|
||||
intervals.push(time.clone()); // Clone to preserve moment with timezone
|
||||
const utcOffset = time.utcOffset();
|
||||
time = time.add(interval, 'minutes').seconds(0).milliseconds(0);
|
||||
|
||||
|
|
@ -51,14 +51,179 @@ export const getTimeInIntervals = (startTime: Moment, interval = CUSTOM_STATUS_T
|
|||
return intervals;
|
||||
};
|
||||
|
||||
// Parse time string - supports HH:MM, H:MM, 12am, 12:30pm, 14:30, etc.
|
||||
// No rounding - returns exact parsed hours and minutes
|
||||
export const parseTimeString = (input: string): {hours: number; minutes: number} | null => {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = input.trim().toLowerCase();
|
||||
|
||||
// Check for AM/PM
|
||||
const hasAM = (/am?$/).test(trimmed);
|
||||
const hasPM = (/pm?$/).test(trimmed);
|
||||
const is12Hour = hasAM || hasPM;
|
||||
|
||||
// Remove AM/PM and extra spaces
|
||||
const timeStr = trimmed.replace(/[ap]m?$/i, '').trim();
|
||||
|
||||
// Match time formats: HH:MM, H:MM, HH, H
|
||||
const match = timeStr.match(/^(\d{1,2}):?(\d{2})?$/);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let hours = parseInt(match[1], 10);
|
||||
const minutes = match[2] ? parseInt(match[2], 10) : 0;
|
||||
|
||||
// Validate ranges
|
||||
if (minutes < 0 || minutes > 59) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is12Hour) {
|
||||
// 12-hour format validation
|
||||
if (hours < 1 || hours > 12) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert to 24-hour
|
||||
if (hasAM) {
|
||||
if (hours === 12) {
|
||||
hours = 0; // 12 AM = 00:00
|
||||
}
|
||||
} else if (hasPM) {
|
||||
if (hours !== 12) {
|
||||
hours += 12; // 1 PM = 13:00, but 12 PM stays 12
|
||||
}
|
||||
}
|
||||
} else if (hours < 0 || hours > 23) {
|
||||
// 24-hour format validation
|
||||
return null;
|
||||
}
|
||||
|
||||
return {hours, minutes};
|
||||
};
|
||||
|
||||
// TimeInputManual - Manual text entry for time (simplified - no rounding, no auto-advance)
|
||||
type TimeInputManualProps = {
|
||||
time: Moment | null;
|
||||
timezone?: string;
|
||||
isMilitaryTime: boolean;
|
||||
onTimeChange: (time: Moment) => void;
|
||||
}
|
||||
|
||||
const TimeInputManual: React.FC<TimeInputManualProps> = ({
|
||||
time,
|
||||
timezone,
|
||||
isMilitaryTime,
|
||||
onTimeChange,
|
||||
}) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const [timeInputValue, setTimeInputValue] = useState<string>('');
|
||||
const [timeInputError, setTimeInputError] = useState<boolean>(false);
|
||||
const timeInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync input value with time prop changes
|
||||
useEffect(() => {
|
||||
if (time) {
|
||||
const formatted = time.format(isMilitaryTime ? 'HH:mm' : 'h:mm A');
|
||||
setTimeInputValue(formatted);
|
||||
} else {
|
||||
setTimeInputValue('');
|
||||
}
|
||||
}, [time, isMilitaryTime]);
|
||||
|
||||
const handleTimeInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTimeInputValue(event.target.value);
|
||||
setTimeInputError(false); // Clear error as user types
|
||||
}, []);
|
||||
|
||||
const handleTimeInputBlur = useCallback(() => {
|
||||
const parsed = parseTimeString(timeInputValue);
|
||||
|
||||
if (!parsed) {
|
||||
if (timeInputValue.trim() !== '') {
|
||||
setTimeInputError(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a moment with the parsed time on the current date (no rounding)
|
||||
const baseMoment = time ? time.clone() : getCurrentMomentForTimezone(timezone);
|
||||
let targetMoment: Moment;
|
||||
|
||||
if (timezone) {
|
||||
targetMoment = moment.tz([
|
||||
baseMoment.year(),
|
||||
baseMoment.month(),
|
||||
baseMoment.date(),
|
||||
parsed.hours,
|
||||
parsed.minutes,
|
||||
0,
|
||||
0,
|
||||
], timezone);
|
||||
} else {
|
||||
baseMoment.hour(parsed.hours);
|
||||
baseMoment.minute(parsed.minutes);
|
||||
baseMoment.second(0);
|
||||
baseMoment.millisecond(0);
|
||||
targetMoment = baseMoment;
|
||||
}
|
||||
|
||||
// Valid time - update (no auto-advance, no exclusion checking)
|
||||
onTimeChange(targetMoment);
|
||||
setTimeInputError(false);
|
||||
}, [timeInputValue, time, timezone, onTimeChange]);
|
||||
|
||||
const handleTimeInputKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (isKeyPressed(event as any, Constants.KeyCodes.ENTER)) {
|
||||
event.preventDefault();
|
||||
timeInputRef.current?.blur(); // Trigger validation
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='date-time-input-manual'>
|
||||
<label
|
||||
htmlFor='time_input'
|
||||
className='date-time-input__label'
|
||||
>
|
||||
{formatMessage({
|
||||
id: 'datetime.time',
|
||||
defaultMessage: 'Time',
|
||||
})}
|
||||
</label>
|
||||
<input
|
||||
ref={timeInputRef}
|
||||
id='time_input'
|
||||
type='text'
|
||||
className={`date-time-input__text-input${timeInputError ? ' error' : ''}`}
|
||||
value={timeInputValue}
|
||||
onChange={handleTimeInputChange}
|
||||
onBlur={handleTimeInputBlur}
|
||||
onKeyDown={handleTimeInputKeyDown}
|
||||
placeholder={isMilitaryTime ? '13:40' : '1:40 PM'}
|
||||
aria-label={formatMessage({
|
||||
id: 'datetime.time',
|
||||
defaultMessage: 'Time',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
time: Moment;
|
||||
handleChange: (date: Moment) => void;
|
||||
time: Moment | null;
|
||||
handleChange: (date: Moment | null) => void;
|
||||
timezone?: string;
|
||||
setIsInteracting?: (interacting: boolean) => void;
|
||||
relativeDate?: boolean;
|
||||
timePickerInterval?: number;
|
||||
allowPastDates?: boolean;
|
||||
allowManualTimeEntry?: boolean;
|
||||
}
|
||||
|
||||
const DateTimeInputContainer: React.FC<Props> = ({
|
||||
|
|
@ -69,9 +234,13 @@ const DateTimeInputContainer: React.FC<Props> = ({
|
|||
relativeDate,
|
||||
timePickerInterval,
|
||||
allowPastDates = false,
|
||||
allowManualTimeEntry = false,
|
||||
}: Props) => {
|
||||
const currentTime = getCurrentMomentForTimezone(timezone);
|
||||
const displayTime = time; // No automatic default - field stays null until user selects
|
||||
const locale = useSelector(getCurrentLocale);
|
||||
const [timeOptions, setTimeOptions] = useState<Date[]>([]);
|
||||
const isMilitaryTime = useSelector(isUseMilitaryTime);
|
||||
const [timeOptions, setTimeOptions] = useState<Moment[]>([]);
|
||||
const [isPopperOpen, setIsPopperOpen] = useState(false);
|
||||
const [isTimeMenuOpen, setIsTimeMenuOpen] = useState(false);
|
||||
const [menuWidth, setMenuWidth] = useState<string>('200px');
|
||||
|
|
@ -97,9 +266,10 @@ const DateTimeInputContainer: React.FC<Props> = ({
|
|||
}
|
||||
}, [setIsInteracting]);
|
||||
|
||||
const handleTimeChange = useCallback((time: Date) => {
|
||||
handleChange(timezone ? moment.tz(time, timezone) : moment(time));
|
||||
}, [handleChange, timezone]);
|
||||
const handleTimeChange = useCallback((selectedTime: Moment) => {
|
||||
// selectedTime is already a Moment with correct timezone from getTimeInIntervals
|
||||
handleChange(selectedTime.clone().second(0).millisecond(0));
|
||||
}, [handleChange]);
|
||||
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
// Handle escape key for date picker when time menu is not open
|
||||
|
|
@ -118,42 +288,92 @@ const DateTimeInputContainer: React.FC<Props> = ({
|
|||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// Auto-round time if it's not already on an interval boundary
|
||||
// This ensures consistent behavior across all callers (DND, Custom Status, Post Reminder, etc.)
|
||||
// Uses default 30-minute interval if not specified
|
||||
// Skip for manual entry fields (user types exact minutes)
|
||||
useEffect(() => {
|
||||
if (time && !allowManualTimeEntry) {
|
||||
const interval = timePickerInterval || CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES;
|
||||
const rounded = getRoundedTime(time, interval);
|
||||
|
||||
// Only update if the time actually needs rounding
|
||||
if (!rounded.isSame(time, 'minute')) {
|
||||
handleChange(rounded);
|
||||
}
|
||||
}
|
||||
}, [time, timePickerInterval, handleChange, allowManualTimeEntry]);
|
||||
|
||||
const setTimeAndOptions = () => {
|
||||
const currentTime = getCurrentMomentForTimezone(timezone);
|
||||
let startTime = moment(time).startOf('day');
|
||||
// Use displayTime if available, otherwise use currentTime for generating dropdown
|
||||
// This ensures dropdown always has options even for optional fields with null time
|
||||
const timeForOptions = displayTime || currentTime;
|
||||
|
||||
// Use clone() to preserve timezone information
|
||||
let startTime = timeForOptions.clone().startOf('day');
|
||||
|
||||
// For form fields (allowPastDates=true), always start from beginning of day
|
||||
// For scheduling (allowPastDates=false), restrict to current time if today
|
||||
if (!allowPastDates && currentTime.isSame(time, 'date')) {
|
||||
if (!allowPastDates && currentTime.isSame(timeForOptions, 'date')) {
|
||||
startTime = getRoundedTime(currentTime, timePickerInterval);
|
||||
}
|
||||
|
||||
setTimeOptions(getTimeInIntervals(startTime, timePickerInterval));
|
||||
};
|
||||
|
||||
useEffect(setTimeAndOptions, [time]);
|
||||
useEffect(setTimeAndOptions, [displayTime, timePickerInterval, allowPastDates, timezone]);
|
||||
|
||||
const handleDayChange = (day: Date, modifiers: DayModifiers) => {
|
||||
// Use existing time if available, otherwise use current time in display timezone
|
||||
let effectiveTime = displayTime;
|
||||
if (!effectiveTime) {
|
||||
// Get current time in the display timezone
|
||||
const nowInTimezone = getCurrentMomentForTimezone(timezone);
|
||||
|
||||
// For manual entry, use exact time (no rounding)
|
||||
// For dropdown, use rounded time
|
||||
effectiveTime = allowManualTimeEntry ?
|
||||
nowInTimezone :
|
||||
getRoundedTime(nowInTimezone, timePickerInterval || 60);
|
||||
}
|
||||
|
||||
if (modifiers.today) {
|
||||
const baseTime = getCurrentMomentForTimezone(timezone);
|
||||
if (!allowPastDates && isBeforeTime(baseTime, time)) {
|
||||
baseTime.hour(time.hours());
|
||||
baseTime.minute(time.minutes());
|
||||
if (!allowPastDates && isBeforeTime(baseTime, effectiveTime)) {
|
||||
baseTime.hour(effectiveTime.hours());
|
||||
baseTime.minute(effectiveTime.minutes());
|
||||
}
|
||||
const roundedTime = getRoundedTime(baseTime, timePickerInterval);
|
||||
handleChange(roundedTime);
|
||||
} else if (timezone) {
|
||||
// Use moment.tz array syntax to create moment directly in timezone
|
||||
// This is the same pattern used by manual entry (which works correctly)
|
||||
const dayMoment = moment(day);
|
||||
const targetDate = moment.tz([
|
||||
dayMoment.year(),
|
||||
dayMoment.month(),
|
||||
dayMoment.date(),
|
||||
effectiveTime.hour(),
|
||||
effectiveTime.minute(),
|
||||
0,
|
||||
0,
|
||||
], timezone);
|
||||
|
||||
handleChange(targetDate);
|
||||
} else {
|
||||
day.setHours(time.hour(), time.minute());
|
||||
const dayWithTimezone = timezone ? moment(day).tz(timezone, true) : moment(day);
|
||||
handleChange(dayWithTimezone);
|
||||
day.setHours(effectiveTime.hour(), effectiveTime.minute());
|
||||
handleChange(moment(day));
|
||||
}
|
||||
handlePopperOpenState(false);
|
||||
};
|
||||
|
||||
const currentTime = getCurrentMomentForTimezone(timezone).toDate();
|
||||
|
||||
const formatDate = (date: Moment): string => {
|
||||
return relativeDate ? relativeFormatDate(date, formatMessage) : DateTime.fromJSDate(date.toDate()).toLocaleString();
|
||||
if (relativeDate) {
|
||||
return relativeFormatDate(date, formatMessage);
|
||||
}
|
||||
|
||||
// Use centralized date formatting utility
|
||||
return formatDateForDisplay(date.toDate(), locale);
|
||||
};
|
||||
|
||||
const calendarIcon = (
|
||||
|
|
@ -167,12 +387,10 @@ const DateTimeInputContainer: React.FC<Props> = ({
|
|||
const datePickerProps: DayPickerProps = {
|
||||
initialFocus: isPopperOpen,
|
||||
mode: 'single',
|
||||
selected: time.toDate(),
|
||||
defaultMonth: time.toDate(),
|
||||
selected: displayTime?.toDate(),
|
||||
defaultMonth: displayTime?.toDate() || new Date(),
|
||||
onDayClick: handleDayChange,
|
||||
disabled: allowPastDates ? undefined : [{
|
||||
before: currentTime,
|
||||
}],
|
||||
disabled: allowPastDates ? undefined : {before: currentTime.toDate()},
|
||||
showOutsideDays: true,
|
||||
};
|
||||
|
||||
|
|
@ -189,67 +407,75 @@ const DateTimeInputContainer: React.FC<Props> = ({
|
|||
defaultMessage: 'Date',
|
||||
})}
|
||||
icon={calendarIcon}
|
||||
value={formatDate(time)}
|
||||
value={displayTime ? formatDate(displayTime) : ''}
|
||||
>
|
||||
<></>
|
||||
<span className='date-time-input__placeholder'>
|
||||
{formatMessage({
|
||||
id: 'datetime.select_date',
|
||||
defaultMessage: 'Select date',
|
||||
})}
|
||||
</span>
|
||||
</DatePicker>
|
||||
</div>
|
||||
<div
|
||||
className='dateTime__time'
|
||||
ref={timeContainerRef}
|
||||
>
|
||||
<Menu.Container
|
||||
menuButton={{
|
||||
id: 'time_button',
|
||||
dataTestId: 'time_button',
|
||||
'aria-label': formatMessage({
|
||||
id: 'datetime.time',
|
||||
defaultMessage: 'Time',
|
||||
}),
|
||||
class: isTimeMenuOpen ? 'date-time-input date-time-input--open' : 'date-time-input',
|
||||
children: (
|
||||
<>
|
||||
<span className='date-time-input__label'>{formatMessage({
|
||||
id: 'datetime.time',
|
||||
defaultMessage: 'Time',
|
||||
})}</span>
|
||||
<span className='date-time-input__icon'>{clockIcon}</span>
|
||||
<span className='date-time-input__value'>
|
||||
<Timestamp
|
||||
useRelative={false}
|
||||
useDate={false}
|
||||
value={time.toString()}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
menu={{
|
||||
id: 'expiryTimeMenu',
|
||||
'aria-label': formatMessage({id: 'time_dropdown.choose_time', defaultMessage: 'Choose a time'}),
|
||||
onToggle: handleTimeMenuToggle,
|
||||
width: menuWidth,
|
||||
className: 'time-menu-scrollable',
|
||||
}}
|
||||
>
|
||||
{timeOptions.map((option, index) => (
|
||||
<Menu.Item
|
||||
key={index}
|
||||
id={`time_option_${index}`}
|
||||
data-testid={`time_option_${index}`}
|
||||
labels={
|
||||
<span>
|
||||
<Timestamp
|
||||
useRelative={false}
|
||||
useDate={false}
|
||||
value={option}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
onClick={() => handleTimeChange(option)}
|
||||
/>
|
||||
))}
|
||||
</Menu.Container>
|
||||
{allowManualTimeEntry ? (
|
||||
<TimeInputManual
|
||||
time={displayTime}
|
||||
timezone={timezone}
|
||||
isMilitaryTime={isMilitaryTime}
|
||||
onTimeChange={handleTimeChange}
|
||||
/>
|
||||
) : (
|
||||
<Menu.Container
|
||||
menuButton={{
|
||||
id: 'time_button',
|
||||
dataTestId: 'time_button',
|
||||
'aria-label': formatMessage({
|
||||
id: 'datetime.time',
|
||||
defaultMessage: 'Time',
|
||||
}),
|
||||
class: isTimeMenuOpen ? 'date-time-input date-time-input--open' : 'date-time-input',
|
||||
children: (
|
||||
<>
|
||||
<span className='date-time-input__label'>{formatMessage({
|
||||
id: 'datetime.time',
|
||||
defaultMessage: 'Time',
|
||||
})}</span>
|
||||
<span className='date-time-input__icon'>{clockIcon}</span>
|
||||
<span className='date-time-input__value'>
|
||||
{displayTime ? (
|
||||
<span>{displayTime.format(isMilitaryTime ? 'HH:mm' : 'LT')}</span>
|
||||
) : (
|
||||
<span>{'--:--'}</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
menu={{
|
||||
id: 'expiryTimeMenu',
|
||||
'aria-label': formatMessage({id: 'time_dropdown.choose_time', defaultMessage: 'Choose a time'}),
|
||||
onToggle: handleTimeMenuToggle,
|
||||
width: menuWidth,
|
||||
className: 'time-menu-scrollable',
|
||||
}}
|
||||
>
|
||||
{timeOptions.map((option, index) => (
|
||||
<Menu.Item
|
||||
key={index}
|
||||
id={`time_option_${index}`}
|
||||
data-testid={`time_option_${index}`}
|
||||
labels={
|
||||
<span>{option.format(isMilitaryTime ? 'HH:mm' : 'LT')}</span>
|
||||
}
|
||||
onClick={() => handleTimeChange(option)}
|
||||
/>
|
||||
))}
|
||||
</Menu.Container>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -4193,6 +4193,7 @@
|
|||
"date_separator.tomorrow": "Tomorrow",
|
||||
"date_separator.yesterday": "Yesterday",
|
||||
"datetime.date": "Date",
|
||||
"datetime.select_date": "Select date",
|
||||
"datetime.time": "Time",
|
||||
"datetime.today": "today",
|
||||
"datetime.yesterday": "yesterday",
|
||||
|
|
|
|||
|
|
@ -223,3 +223,40 @@ input::-webkit-file-upload-button {
|
|||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
// Manual time entry styles
|
||||
.date-time-input-manual {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.date-time-input__label {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date-time-input__text-input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 4px;
|
||||
background: var(--center-channel-bg);
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--button-bg);
|
||||
box-shadow: 0 0 0 2px rgba(var(--button-bg-rgb), 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--error-text);
|
||||
box-shadow: 0 0 0 2px rgba(var(--error-text-color-rgb), 0.12);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.48);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
stringToMoment,
|
||||
momentToString,
|
||||
resolveRelativeDate,
|
||||
formatDateForDisplay,
|
||||
} from './date_utils';
|
||||
|
||||
describe('date_utils', () => {
|
||||
|
|
@ -359,4 +360,56 @@ describe('date_utils', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateForDisplay', () => {
|
||||
it('should format date with short month and numeric day/year', () => {
|
||||
const date = new Date('2025-01-15T10:00:00Z');
|
||||
const result = formatDateForDisplay(date, 'en-US');
|
||||
|
||||
// Should match pattern: "Jan 15, 2025"
|
||||
expect(result).toMatch(/^[A-Z][a-z]{2} \d{1,2}, \d{4}$/);
|
||||
expect(result).toContain('Jan');
|
||||
expect(result).toContain('15');
|
||||
expect(result).toContain('2025');
|
||||
});
|
||||
|
||||
it('should use provided locale', () => {
|
||||
const date = new Date('2025-01-15T10:00:00Z');
|
||||
const result = formatDateForDisplay(date, 'en-US');
|
||||
|
||||
// Format should be consistent regardless of system locale
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should fallback to navigator language if no locale provided', () => {
|
||||
const date = new Date('2025-01-15T10:00:00Z');
|
||||
const result = formatDateForDisplay(date);
|
||||
|
||||
// Should still format successfully using default locale
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toContain('15');
|
||||
expect(result).toContain('2025');
|
||||
});
|
||||
|
||||
it('should handle invalid dates gracefully', () => {
|
||||
const invalidDate = new Date('invalid');
|
||||
const result = formatDateForDisplay(invalidDate, 'en-US');
|
||||
|
||||
// Should return fallback without throwing
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should format dates consistently for datetime fields', () => {
|
||||
const date1 = new Date('2025-06-15T14:30:00Z');
|
||||
const date2 = new Date('2025-12-25T09:00:00Z');
|
||||
|
||||
const result1 = formatDateForDisplay(date1, 'en-US');
|
||||
const result2 = formatDateForDisplay(date2, 'en-US');
|
||||
|
||||
// Both should use same format pattern
|
||||
expect(result1).toMatch(/^[A-Z][a-z]{2} \d{1,2}, \d{4}$/);
|
||||
expect(result2).toMatch(/^[A-Z][a-z]{2} \d{1,2}, \d{4}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,25 @@ export enum DateReference {
|
|||
export const DATE_FORMAT = 'yyyy-MM-dd';
|
||||
const MOMENT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ss[Z]';
|
||||
|
||||
/**
|
||||
* Format a date for display using user's locale
|
||||
* Consistent format: "Jan 15, 2025" (short month, numeric day and year)
|
||||
* Centralizes date formatting so it can be changed in one place
|
||||
*/
|
||||
export function formatDateForDisplay(date: Date, locale?: string): string {
|
||||
try {
|
||||
const userLocale = locale || navigator.language || 'en-US';
|
||||
return new Intl.DateTimeFormat(userLocale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
} catch {
|
||||
// Fallback to ISO format if locale formatting fails
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string value (ISO format or relative) to a Moment object
|
||||
* For date-only fields, datetime formats are accepted and the date portion is extracted
|
||||
|
|
@ -60,7 +79,8 @@ export function momentToString(momentValue: Moment | null, isDateTime: boolean):
|
|||
}
|
||||
|
||||
if (isDateTime) {
|
||||
return momentValue.utc().format(MOMENT_DATETIME_FORMAT);
|
||||
// Clone to avoid mutating the original moment when converting to UTC
|
||||
return momentValue.clone().utc().format(MOMENT_DATETIME_FORMAT);
|
||||
}
|
||||
|
||||
// Store date only: "2025-01-14"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue