From 656a0248ebcade5e5e88a7d5db99c138ed88ddd9 Mon Sep 17 00:00:00 2001 From: Adam Schildkraut <38878185+Adam-Schildkraut@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:19:15 +1100 Subject: [PATCH] Fix datetime MinDate/MaxDate validation and add sub-day relative patterns (#35327) * Add H/M/S sub-day units to validateRelativePattern * Fix datetime MinDate/MaxDate to use validateDateTimeFormat * Add H/M/S sub-day resolution to resolveRelativeDateToMoment * Add minDateTime/maxDateTime props to DateTimeInput * Wire min_date/max_date resolution in AppsFormDateTimeField * Align client relative pattern bounds with server validation * Fix allowPastDates when minDateTime is in the past --- .../channels/api4/integration_action_test.go | 29 ++---- server/channels/app/integration_action.go | 8 +- .../channels/app/integration_action_test.go | 5 +- server/i18n/en.json | 4 + server/public/model/integration_action.go | 37 ++++--- .../public/model/integration_action_test.go | 76 ++++++++++++++- .../apps_form/apps_form_component.tsx | 21 +++- .../apps_form_datetime_field.tsx | 29 +++--- .../datetime_input/datetime_input.tsx | 96 ++++++++++++++++--- .../src/components/dialog_router/index.ts | 2 + .../interactive_dialog_adapter.tsx | 2 + webapp/channels/src/i18n/en.json | 4 +- .../src/utils/integration_utils.test.ts | 52 ++++++++++ .../src/utils/integration_utils.ts | 67 ++++++++++++- webapp/channels/src/utils/date_utils.test.ts | 28 +++++- webapp/channels/src/utils/date_utils.ts | 26 ++--- 16 files changed, 393 insertions(+), 93 deletions(-) diff --git a/server/channels/api4/integration_action_test.go b/server/channels/api4/integration_action_test.go index 7c85d462d2c..bcbe4b842bb 100644 --- a/server/channels/api4/integration_action_test.go +++ b/server/channels/api4/integration_action_test.go @@ -16,8 +16,6 @@ import ( "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/public/shared/mlog" - "github.com/mattermost/mattermost/server/v8/channels/testlib" ) type testHandler struct { @@ -212,7 +210,7 @@ func TestOpenDialog(t *testing.T) { require.NoError(t, err) }) - t.Run("Should pass with too long display name of elements", func(t *testing.T) { + t.Run("Should reject dialog with too long display name of elements", func(t *testing.T) { request.Dialog.Elements = []model.DialogElement{ { DisplayName: "Very very long Element Name", @@ -222,18 +220,12 @@ func TestOpenDialog(t *testing.T) { }, } - buffer := &mlog.Buffer{} - err := mlog.AddWriterTarget(th.TestLogger, buffer, true, mlog.StdAll...) - require.NoError(t, err) - - _, err = client.OpenInteractiveDialog(context.Background(), request) - require.NoError(t, err) - - require.NoError(t, th.TestLogger.Flush()) - testlib.AssertLog(t, buffer, mlog.LvlWarn.Name, "Interactive dialog is invalid") + resp, err := client.OpenInteractiveDialog(context.Background(), request) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) - t.Run("Should pass with same elements", func(t *testing.T) { + t.Run("Should reject dialog with duplicate elements", func(t *testing.T) { request.Dialog.Elements = []model.DialogElement{ { DisplayName: "Element Name", @@ -248,15 +240,10 @@ func TestOpenDialog(t *testing.T) { Placeholder: "Enter a value", }, } - buffer := &mlog.Buffer{} - err := mlog.AddWriterTarget(th.TestLogger, buffer, true, mlog.StdAll...) - require.NoError(t, err) - _, err = client.OpenInteractiveDialog(context.Background(), request) - require.NoError(t, err) - - require.NoError(t, th.TestLogger.Flush()) - testlib.AssertLog(t, buffer, mlog.LvlWarn.Name, "Interactive dialog is invalid") + resp, err := client.OpenInteractiveDialog(context.Background(), request) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("Should pass with nil elements slice", func(t *testing.T) { diff --git a/server/channels/app/integration_action.go b/server/channels/app/integration_action.go index 9dc874af8d5..3956c0bad1b 100644 --- a/server/channels/app/integration_action.go +++ b/server/channels/app/integration_action.go @@ -471,12 +471,12 @@ func (a *App) OpenInteractiveDialog(rctx request.CTX, request model.OpenDialogRe return appErr } - if dialogErr := request.IsValid(); dialogErr != nil { - rctx.Logger().Warn("Interactive dialog is invalid", mlog.Err(dialogErr)) - } - request.TriggerId = clientTriggerId + if dialogErr := request.IsValid(); dialogErr != nil { + return model.NewAppError("OpenInteractiveDialog", "app.interactive_dialog.invalid", nil, "", http.StatusBadRequest).Wrap(dialogErr) + } + jsonRequest, err := json.Marshal(request) if err != nil { a.ch.srv.Log().Warn("Error encoding request", mlog.Err(err)) diff --git a/server/channels/app/integration_action_test.go b/server/channels/app/integration_action_test.go index ded357a36af..7a99cb56721 100644 --- a/server/channels/app/integration_action_test.go +++ b/server/channels/app/integration_action_test.go @@ -1521,9 +1521,10 @@ func TestOpenInteractiveDialog(t *testing.T) { }, } - // Should succeed but log warning about invalid dialog + // Should fail with bad request since dialog has invalid element err = th.App.OpenInteractiveDialog(th.Context, request) - require.Nil(t, err) + require.NotNil(t, err) + require.Equal(t, http.StatusBadRequest, err.StatusCode) }) } diff --git a/server/i18n/en.json b/server/i18n/en.json index b60d3bfac7b..7eceb5b0100 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -6710,6 +6710,10 @@ "id": "app.insert_error", "translation": "insert error" }, + { + "id": "app.interactive_dialog.invalid", + "translation": "Invalid interactive dialog." + }, { "id": "app.job.download_export_results_not_enabled", "translation": "DownloadExportResults in config.json is false. Please set this to true to download the results of this job." diff --git a/server/public/model/integration_action.go b/server/public/model/integration_action.go index 65aaf314073..a41b30ae88c 100644 --- a/server/public/model/integration_action.go +++ b/server/public/model/integration_action.go @@ -677,16 +677,16 @@ func (e *DialogElement) IsValid() error { multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementTextMaxLength)) multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementTextMaxLength)) multiErr = multierror.Append(multiErr, validateDateTimeFormat(e.Default)) - multiErr = multierror.Append(multiErr, validateDateFormat(e.MinDate)) - multiErr = multierror.Append(multiErr, validateDateFormat(e.MaxDate)) - // Validate time_interval for datetime fields + multiErr = multierror.Append(multiErr, validateDateOrDateTimeFormat(e.MinDate)) + multiErr = multierror.Append(multiErr, validateDateOrDateTimeFormat(e.MaxDate)) + // Validate time_interval for datetime fields (0 means omitted — treated as default) timeInterval := e.TimeInterval - if timeInterval == 0 { - multiErr = multierror.Append(multiErr, errors.Errorf("time_interval of 0 will be reset to default, %d minutes", DefaultTimeIntervalMinutes)) - } else if timeInterval < 1 || timeInterval > 1440 { - multiErr = multierror.Append(multiErr, errors.Errorf("time_interval must be between 1 and 1440 minutes, got %d", timeInterval)) - } else if 1440%timeInterval != 0 { - multiErr = multierror.Append(multiErr, errors.Errorf("time_interval must be a divisor of 1440 (24 hours * 60 minutes) to create valid time intervals, got %d", timeInterval)) + if timeInterval != 0 { + if timeInterval < 1 || timeInterval > 1440 { + multiErr = multierror.Append(multiErr, errors.Errorf("time_interval must be between 1 and 1440 minutes, got %d", timeInterval)) + } else if 1440%timeInterval != 0 { + multiErr = multierror.Append(multiErr, errors.Errorf("time_interval must be a divisor of 1440 (24 hours * 60 minutes) to create valid time intervals, got %d", timeInterval)) + } } default: @@ -734,14 +734,15 @@ func isMultiSelectDefaultInOptions(defaultValue string, options []*PostActionOpt return true } -// validateRelativePattern validates relative date patterns like +1d, +2w, +1m +// validateRelativePattern validates relative date patterns like +1d, +2w, +1m, +2H, +30M, +90S +// Case-sensitive: d=days, w=weeks, m=months, H=hours, M=minutes, S=seconds func validateRelativePattern(value string) bool { if len(value) < 3 || len(value) > 5 || (value[0] != '+' && value[0] != '-') { return false } - lastChar := strings.ToLower(string(value[len(value)-1])) - if !strings.Contains("dwm", lastChar) { + lastChar := value[len(value)-1] + if !strings.ContainsRune("dwmHMS", rune(lastChar)) { return false } @@ -794,6 +795,18 @@ func validateDateTimeFormat(dateTimeStr string) error { return fmt.Errorf("invalid datetime format: %q, expected ISO format (YYYY-MM-DDTHH:MM:SSZ) or relative format", dateTimeStr) } +func validateDateOrDateTimeFormat(value string) error { + dateErr := validateDateFormat(value) + if dateErr == nil { + return nil + } + dateTimeErr := validateDateTimeFormat(value) + if dateTimeErr == nil { + return nil + } + return fmt.Errorf("invalid date or datetime format: %q, expected ISO date (YYYY-MM-DD), datetime (YYYY-MM-DDTHH:MM:SSZ), or relative format", value) +} + func checkMaxLength(fieldName string, field string, maxLength int) error { // DisplayName and Name are required fields if fieldName == "DisplayName" || fieldName == "Name" { diff --git a/server/public/model/integration_action_test.go b/server/public/model/integration_action_test.go index 3964b629e7e..d7b06fcdea2 100644 --- a/server/public/model/integration_action_test.go +++ b/server/public/model/integration_action_test.go @@ -1280,6 +1280,45 @@ func TestSubmitDialogResponse_IsValid(t *testing.T) { } } +func TestValidateRelativePattern(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"valid days", "+1d", true}, + {"valid weeks", "+2w", true}, + {"valid months", "+3m", true}, + {"valid hours", "+2H", true}, + {"valid minutes", "+30M", true}, + {"valid seconds", "+90S", true}, + {"negative days", "-1d", true}, + {"negative hours", "-2H", true}, + {"multi-digit number", "+99d", true}, + {"max digits", "+999d", true}, + {"lowercase h rejected", "+1h", false}, + {"lowercase s rejected", "+1s", false}, + {"uppercase D rejected", "+1D", false}, + {"uppercase W rejected", "+1W", false}, + {"no number", "+d", false}, + {"empty", "", false}, + {"too long days", "+9999d", false}, + {"too long hours", "+9999H", false}, + {"too long minutes", "+9999M", false}, + {"too long seconds", "+9999S", false}, + {"no number hours", "+H", false}, + {"no number minutes", "+M", false}, + {"no number seconds", "+S", false}, + {"no sign", "1d", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, validateRelativePattern(tt.input)) + }) + } +} + func TestValidateDateFormat(t *testing.T) { tests := []struct { name string @@ -1359,6 +1398,34 @@ func TestDialogElementDateTimeValidation(t *testing.T) { }) t.Run("should validate DialogElement with datetime type and time properties", func(t *testing.T) { + element := DialogElement{ + DisplayName: "Test DateTime", + Name: "test_datetime", + Type: "datetime", + MinDate: "2025-01-01T00:00:00Z", + MaxDate: "2025-12-31T23:59:59Z", + TimeInterval: 30, + Optional: false, + } + err := element.IsValid() + assert.NoError(t, err) + }) + + t.Run("should validate DialogElement with datetime type and relative min/max", func(t *testing.T) { + element := DialogElement{ + DisplayName: "Test DateTime", + Name: "test_datetime", + Type: "datetime", + MinDate: "+2H", + MaxDate: "+7d", + TimeInterval: 30, + Optional: false, + } + err := element.IsValid() + assert.NoError(t, err) + }) + + t.Run("should accept datetime DialogElement with date-only min/max for backward compatibility", func(t *testing.T) { element := DialogElement{ DisplayName: "Test DateTime", Name: "test_datetime", @@ -1445,7 +1512,7 @@ func TestDialogElementDateTimeValidation(t *testing.T) { }) t.Run("should use default time_interval of 60 minutes when zero", func(t *testing.T) { - // Valid with default 60-minute interval + // Valid with explicit 60-minute interval element := DialogElement{ DisplayName: "Test DateTime", Name: "test_datetime", @@ -1456,16 +1523,15 @@ func TestDialogElementDateTimeValidation(t *testing.T) { err := element.IsValid() assert.NoError(t, err) - // Invalid with default 60-minute interval + // time_interval=0 means omitted — treated as default, should pass validation element = DialogElement{ DisplayName: "Test DateTime", Name: "test_datetime", Type: "datetime", - TimeInterval: 0, // Should use default of 60 + TimeInterval: 0, Optional: false, } err = element.IsValid() - assert.Error(t, err) - assert.Contains(t, err.Error(), "time_interval of 0 will be reset to default") + assert.NoError(t, err) }) } diff --git a/webapp/channels/src/components/apps_form/apps_form_component.tsx b/webapp/channels/src/components/apps_form/apps_form_component.tsx index 21b49545d17..39efda5be43 100644 --- a/webapp/channels/src/components/apps_form/apps_form_component.tsx +++ b/webapp/channels/src/components/apps_form/apps_form_component.tsx @@ -164,8 +164,8 @@ const createSanitizedField = (field: AppField): AppField => { } } - // Sanitize date values for date/datetime fields - if (field.type === AppFieldTypes.DATE || field.type === AppFieldTypes.DATETIME) { + // Sanitize date values for date fields only — datetime fields need the full pattern preserved + if (field.type === AppFieldTypes.DATE) { if (field.min_date) { sanitized.min_date = getSafeDateValue(field.min_date); } @@ -212,9 +212,20 @@ const initFormValues = (form: AppForm, timezone?: string): AppFormValues => { // Round up to next time interval const minutesMod = currentTime.minutes() % timePickerInterval; - const defaultMoment = minutesMod === 0 ? + let defaultMoment = minutesMod === 0 ? currentTime.clone().seconds(0).milliseconds(0) : currentTime.clone().add(timePickerInterval - minutesMod, 'minutes').seconds(0).milliseconds(0); + + // Clamp default to min_date/max_date bounds + const minMoment = field.min_date ? stringToMoment(field.min_date, timezone) : null; + const maxMoment = field.max_date ? stringToMoment(field.max_date, timezone) : null; + if (minMoment && defaultMoment.isBefore(minMoment)) { + defaultMoment = minMoment.clone(); + } + if (maxMoment && defaultMoment.isAfter(maxMoment)) { + defaultMoment = maxMoment.clone(); + } + defaultValue = momentToString(defaultMoment, true); } @@ -248,7 +259,7 @@ export class AppsForm extends React.PureComponent { if (nextProps.form !== prevState.form) { const values = { ...prevState.values, - ...initFormValues(nextProps.form), + ...initFormValues(nextProps.form, nextProps.timezone), }; return { @@ -768,6 +779,8 @@ function fieldsAsElements(fields?: AppField[]): DialogElement[] { type: f.type, subtype: f.subtype, optional: !f.is_required, + min_date: f.min_date, + max_date: f.max_date, })) as DialogElement[]; } diff --git a/webapp/channels/src/components/apps_form/apps_form_datetime_field/apps_form_datetime_field.tsx b/webapp/channels/src/components/apps_form/apps_form_datetime_field/apps_form_datetime_field.tsx index 8532752b08c..4c72f11e91d 100644 --- a/webapp/channels/src/components/apps_form/apps_form_datetime_field/apps_form_datetime_field.tsx +++ b/webapp/channels/src/components/apps_form/apps_form_datetime_field/apps_form_datetime_field.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import moment from 'moment-timezone'; +import type moment from 'moment-timezone'; import React, {useCallback, useMemo} from 'react'; import {useSelector} from 'react-redux'; @@ -11,7 +11,8 @@ import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; import DateTimeInput from 'components/datetime_input/datetime_input'; -import {stringToMoment, momentToString, resolveRelativeDate} from 'utils/date_utils'; +import {stringToMoment, momentToString} from 'utils/date_utils'; +import {getCurrentMomentForTimezone} from 'utils/timezone'; // Default time interval for DateTime fields in minutes const DEFAULT_TIME_INTERVAL_MINUTES = 60; @@ -81,18 +82,22 @@ const AppsFormDateTimeField: React.FC = ({ onChange(field.name, newValue); }, [field.name, onChange]); - const allowPastDates = useMemo(() => { - if (field.min_date) { - const resolvedMinDate = resolveRelativeDate(field.min_date); - const minMoment = stringToMoment(resolvedMinDate, timezone); - const currentMoment = timezone ? moment.tz(timezone) : moment(); - - return !minMoment || minMoment.isBefore(currentMoment, 'day'); + const {minDateTime, allowPastDates} = useMemo(() => { + if (!field.min_date) { + return {minDateTime: undefined, allowPastDates: true}; } - - return true; + const min = stringToMoment(field.min_date, timezone) ?? undefined; + const now = getCurrentMomentForTimezone(timezone); + return {minDateTime: min, allowPastDates: !min || min.isBefore(now, 'minute')}; }, [field.min_date, timezone]); + const maxDateTime = useMemo(() => { + if (!field.max_date) { + return undefined; + } + return stringToMoment(field.max_date, timezone) ?? undefined; + }, [field.max_date, timezone]); + return (
{showTimezoneIndicator && ( @@ -109,6 +114,8 @@ const AppsFormDateTimeField: React.FC = ({ allowPastDates={allowPastDates} allowManualTimeEntry={allowManualTimeEntry} setIsInteracting={setIsInteracting} + minDateTime={minDateTime} + maxDateTime={maxDateTime} />
); diff --git a/webapp/channels/src/components/datetime_input/datetime_input.tsx b/webapp/channels/src/components/datetime_input/datetime_input.tsx index 9b41df3e486..fc924279e0a 100644 --- a/webapp/channels/src/components/datetime_input/datetime_input.tsx +++ b/webapp/channels/src/components/datetime_input/datetime_input.tsx @@ -3,7 +3,7 @@ import type {Moment} from 'moment-timezone'; import moment from 'moment-timezone'; -import React, {useEffect, useState, useCallback, useRef} from 'react'; +import React, {useEffect, useMemo, useState, useCallback, useRef} from 'react'; import type {DayModifiers, DayPickerProps} from 'react-day-picker'; import {useIntl} from 'react-intl'; import {useSelector} from 'react-redux'; @@ -22,6 +22,10 @@ import {getCurrentMomentForTimezone, isBeforeTime} from 'utils/timezone'; const CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES = 30; +function momentToLocalDate(m: Moment): Date { + return new Date(m.year(), m.month(), m.date()); +} + export function getRoundedTime(value: Moment, roundedTo = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES): Moment { const diff = value.minute() % roundedTo; if (diff === 0) { @@ -113,6 +117,8 @@ type TimeInputManualProps = { timezone?: string; isMilitaryTime: boolean; onTimeChange: (time: Moment) => void; + minDateTime?: Moment; + maxDateTime?: Moment; } const TimeInputManual: React.FC = ({ @@ -120,6 +126,8 @@ const TimeInputManual: React.FC = ({ timezone, isMilitaryTime, onTimeChange, + minDateTime, + maxDateTime, }) => { const {formatMessage} = useIntl(); const [timeInputValue, setTimeInputValue] = useState(''); @@ -173,10 +181,17 @@ const TimeInputManual: React.FC = ({ targetMoment = baseMoment; } + if (minDateTime && targetMoment.isBefore(minDateTime, 'minute')) { + targetMoment = minDateTime.clone(); + } + if (maxDateTime && targetMoment.isAfter(maxDateTime, 'minute')) { + targetMoment = maxDateTime.clone(); + } + // Valid time - update (no auto-advance, no exclusion checking) onTimeChange(targetMoment); setTimeInputError(false); - }, [timeInputValue, time, timezone, onTimeChange]); + }, [timeInputValue, time, timezone, onTimeChange, minDateTime, maxDateTime]); const handleTimeInputKeyDown = useCallback((event: React.KeyboardEvent) => { if (isKeyPressed(event as any, Constants.KeyCodes.ENTER)) { @@ -215,6 +230,8 @@ type Props = { timePickerInterval?: number; allowPastDates?: boolean; allowManualTimeEntry?: boolean; + minDateTime?: Moment; + maxDateTime?: Moment; } const DateTimeInputContainer: React.FC = ({ @@ -226,6 +243,8 @@ const DateTimeInputContainer: React.FC = ({ timePickerInterval, allowPastDates = false, allowManualTimeEntry = false, + minDateTime, + maxDateTime, }: Props) => { const currentTime = getCurrentMomentForTimezone(timezone); const displayTime = time; // No automatic default - field stays null until user selects @@ -259,8 +278,15 @@ const DateTimeInputContainer: React.FC = ({ const handleTimeChange = useCallback((selectedTime: Moment) => { // selectedTime is already a Moment with correct timezone from getTimeInIntervals - handleChange(selectedTime.clone().second(0).millisecond(0)); - }, [handleChange]); + let result = selectedTime.clone().second(0).millisecond(0); + if (minDateTime) { + result = moment.max(result, minDateTime); + } + if (maxDateTime) { + result = moment.min(result, maxDateTime); + } + handleChange(result); + }, [handleChange, minDateTime, maxDateTime]); const handleKeyDown = useCallback((event: KeyboardEvent) => { // Handle escape key for date picker when time menu is not open @@ -309,10 +335,19 @@ const DateTimeInputContainer: React.FC = ({ startTime = getRoundedTime(currentTime, timePickerInterval); } - setTimeOptions(getTimeInIntervals(startTime, timePickerInterval)); + let options = getTimeInIntervals(startTime, timePickerInterval); + + if (minDateTime && timeForOptions.isSame(minDateTime, 'date')) { + options = options.filter((opt) => !opt.isBefore(minDateTime, 'minute')); + } + if (maxDateTime && timeForOptions.isSame(maxDateTime, 'date')) { + options = options.filter((opt) => !opt.isAfter(maxDateTime, 'minute')); + } + + setTimeOptions(options); }; - useEffect(setTimeAndOptions, [displayTime, timePickerInterval, allowPastDates, timezone]); + useEffect(setTimeAndOptions, [displayTime, timePickerInterval, allowPastDates, timezone, minDateTime, maxDateTime]); const handleDayChange = (day: Date, modifiers: DayModifiers) => { // Use existing time if available, otherwise use current time in display timezone @@ -328,19 +363,19 @@ const DateTimeInputContainer: React.FC = ({ getRoundedTime(nowInTimezone, timePickerInterval || 60); } + let result: Moment; if (modifiers.today) { const baseTime = getCurrentMomentForTimezone(timezone); if (!allowPastDates && isBeforeTime(baseTime, effectiveTime)) { baseTime.hour(effectiveTime.hours()); baseTime.minute(effectiveTime.minutes()); } - const roundedTime = getRoundedTime(baseTime, timePickerInterval); - handleChange(roundedTime); + result = getRoundedTime(baseTime, timePickerInterval); } 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([ + result = moment.tz([ dayMoment.year(), dayMoment.month(), dayMoment.date(), @@ -349,12 +384,19 @@ const DateTimeInputContainer: React.FC = ({ 0, 0, ], timezone); - - handleChange(targetDate); } else { day.setHours(effectiveTime.hour(), effectiveTime.minute()); - handleChange(moment(day)); + result = moment(day); } + + if (minDateTime) { + result = moment.max(result, minDateTime); + } + if (maxDateTime) { + result = moment.min(result, maxDateTime); + } + + handleChange(result); handlePopperOpenState(false); }; @@ -377,13 +419,35 @@ const DateTimeInputContainer: React.FC = ({ ); + // Use date-only string as dep so the memo only recomputes when the calendar date changes, + // not on every render (currentTime is a new Moment each render). + const todayDateString = currentTime.format('YYYY-MM-DD'); + + const disabledDays = useMemo(() => { + const matchers: Array<{before: Date} | {after: Date}> = []; + if (minDateTime) { + matchers.push({before: momentToLocalDate(minDateTime)}); + } else if (!allowPastDates) { + matchers.push({before: momentToLocalDate(currentTime)}); + } + if (maxDateTime) { + // If maxDateTime is exactly midnight, no time on that day is usable — disable the day itself + if (maxDateTime.isSame(maxDateTime.clone().startOf('day'), 'minute')) { + matchers.push({after: momentToLocalDate(maxDateTime.clone().subtract(1, 'day'))}); + } else { + matchers.push({after: momentToLocalDate(maxDateTime)}); + } + } + return matchers.length > 0 ? matchers : undefined; + }, [minDateTime, maxDateTime, allowPastDates, todayDateString]); // eslint-disable-line react-hooks/exhaustive-deps -- currentTime used inside but todayDateString tracks the relevant change (date only) + const datePickerProps: DayPickerProps = { initialFocus: isPopperOpen, mode: 'single', - selected: displayTime?.toDate(), - defaultMonth: displayTime?.toDate() || new Date(), + selected: displayTime ? momentToLocalDate(displayTime) : undefined, + defaultMonth: displayTime ? momentToLocalDate(displayTime) : new Date(), onDayClick: handleDayChange, - disabled: allowPastDates ? undefined : {before: currentTime.toDate()}, + disabled: disabledDays, showOutsideDays: true, }; @@ -420,6 +484,8 @@ const DateTimeInputContainer: React.FC = ({ timezone={timezone} isMilitaryTime={isMilitaryTime} onTimeChange={handleTimeChange} + minDateTime={minDateTime} + maxDateTime={maxDateTime} /> ) : ( ; + timezone?: string; // Required actions actions: { @@ -676,6 +677,7 @@ class InteractiveDialogAdapter extends React.PureComponent { {})} onHide={this.cancelAdapter} actions={{ diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 50a9209e03a..aea32fadb51 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4971,12 +4971,14 @@ "integrations.successful": "Setup Successful", "interactive_dialog.cancel": "Cancel", "interactive_dialog.element.optional": "(optional)", + "interactive_dialog.error.after_max_date": "Selected time is after the maximum allowed date.", "interactive_dialog.error.bad_date_format": "Date field must be in YYYY-MM-DD format", - "interactive_dialog.error.bad_datetime_format": "DateTime field must be in YYYY-MM-DDTHH:mm:ssZ format", + "interactive_dialog.error.bad_datetime_format": "DateTime field must be in YYYY-MM-DDTHH:mm:ssZ or YYYY-MM-DDTHH:mm:ss+HH:MM format", "interactive_dialog.error.bad_email": "Must be a valid email address.", "interactive_dialog.error.bad_format": "Invalid date format", "interactive_dialog.error.bad_number": "Must be a number.", "interactive_dialog.error.bad_url": "URL must include http:// or https://.", + "interactive_dialog.error.before_min_date": "Selected time is before the minimum allowed date.", "interactive_dialog.error.invalid_option": "Must be a valid option", "interactive_dialog.error.required": "This field is required.", "interactive_dialog.error.too_short": "Minimum input length is {minLength}.", diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/integration_utils.test.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/integration_utils.test.ts index a61f9a57401..2f45af6c687 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/integration_utils.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/integration_utils.test.ts @@ -139,5 +139,57 @@ describe('integration utils', () => { expect(dateError?.id).toBe('interactive_dialog.error.required'); expect(datetimeError?.id).toBe('interactive_dialog.error.required'); }); + + it('should accept valid datetime with timezone offset', () => { + const element = TestHelper.getDialogElementMock({type: 'datetime'}); + expect(checkDialogElementForError(element, '2025-01-15T14:30:00+05:30')).toBeNull(); + expect(checkDialogElementForError(element, '2025-01-15T14:30:00-07:00')).toBeNull(); + }); + + it('should return error when datetime is before min_date', () => { + const element = TestHelper.getDialogElementMock({ + type: 'datetime', + min_date: '2025-06-01T00:00:00Z', + }); + + const error = checkDialogElementForError(element, '2025-05-15T12:00:00Z'); + expect(error?.id).toBe('interactive_dialog.error.before_min_date'); + }); + + it('should return error when datetime is after max_date', () => { + const element = TestHelper.getDialogElementMock({ + type: 'datetime', + max_date: '2025-06-01T00:00:00Z', + }); + + const error = checkDialogElementForError(element, '2025-06-15T12:00:00Z'); + expect(error?.id).toBe('interactive_dialog.error.after_max_date'); + }); + + it('should return null when datetime is within min_date and max_date bounds', () => { + const element = TestHelper.getDialogElementMock({ + type: 'datetime', + min_date: '2025-01-01T00:00:00Z', + max_date: '2025-12-31T23:59:59Z', + }); + + expect(checkDialogElementForError(element, '2025-06-15T12:00:00Z')).toBeNull(); + }); + + it('should skip range validation when min_date/max_date are not set', () => { + const element = TestHelper.getDialogElementMock({type: 'datetime'}); + expect(checkDialogElementForError(element, '2025-01-15T14:30:00Z')).toBeNull(); + }); + + it('should handle unresolvable min_date/max_date gracefully', () => { + const element = TestHelper.getDialogElementMock({ + type: 'datetime', + min_date: 'not-a-valid-format', + max_date: 'also-invalid', + }); + + // Should skip range check (resolveBoundToDate returns null) and pass + expect(checkDialogElementForError(element, '2025-06-15T12:00:00Z')).toBeNull(); + }); }); }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/integration_utils.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/integration_utils.ts index e7adfc7e4df..507869c269a 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/integration_utils.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/integration_utils.ts @@ -1,14 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {parseISO, isValid} from 'date-fns'; +import {parseISO, isValid, addDays, addWeeks, addMonths, addHours, addMinutes, addSeconds, startOfDay} from 'date-fns'; import {defineMessage} from 'react-intl'; import type {DialogElement} from '@mattermost/types/integrations'; // Validation patterns for exact storage format matching const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/; // YYYY-MM-DD -const DATETIME_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; // YYYY-MM-DDTHH:mm:ssZ +const DATETIME_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/; // YYYY-MM-DDTHH:mm:ssZ or with offset + +// Relative pattern: [+-]NNN[dwmHMS] +const RELATIVE_PATTERN = /^([+-]\d{1,3})([dwmHMS])$/; type DialogError = { id: string; @@ -16,6 +19,43 @@ type DialogError = { values?: any; }; +/** + * Resolves a min_date/max_date bound string to a Date. + * Handles relative patterns (+2H, +30M, +7d, etc.) and ISO date/datetime strings. + * Returns null if the value cannot be resolved. + */ +function resolveBoundToDate(value: string): Date | null { + // Named relative words + if (value === 'today') { + return startOfDay(new Date()); + } + if (value === 'tomorrow') { + return startOfDay(addDays(new Date(), 1)); + } + if (value === 'yesterday') { + return startOfDay(addDays(new Date(), -1)); + } + + // Dynamic relative patterns: +2H, +30M, +7d, etc. + const match = value.match(RELATIVE_PATTERN); + if (match) { + const amount = parseInt(match[1], 10); + const unit = match[2]; + const now = new Date(); + switch (unit) { + case 'd': return startOfDay(addDays(now, amount)); + case 'w': return startOfDay(addWeeks(now, amount)); + case 'm': return startOfDay(addMonths(now, amount)); + case 'H': return addHours(now, amount); + case 'M': return addMinutes(now, amount); + case 'S': return addSeconds(now, amount); + default: return null; + } + } + const parsed = parseISO(value); + return isValid(parsed) ? parsed : null; +} + /** * Validates date/datetime field values for format and range constraints */ @@ -39,9 +79,30 @@ function validateDateTimeValue(value: string, elem: DialogElement): DialogError } else if (!DATETIME_FORMAT_PATTERN.test(value)) { return defineMessage({ id: 'interactive_dialog.error.bad_datetime_format', - defaultMessage: 'DateTime field must be in YYYY-MM-DDTHH:mm:ssZ format', + defaultMessage: 'DateTime field must be in YYYY-MM-DDTHH:mm:ssZ or YYYY-MM-DDTHH:mm:ss+HH:MM format', }); } + + // Range validation against min_date / max_date + if (elem.min_date) { + const minDate = resolveBoundToDate(elem.min_date); + if (minDate && parsedDate < minDate) { + return defineMessage({ + id: 'interactive_dialog.error.before_min_date', + defaultMessage: 'Selected time is before the minimum allowed date.', + }); + } + } + if (elem.max_date) { + const maxDate = resolveBoundToDate(elem.max_date); + if (maxDate && parsedDate > maxDate) { + return defineMessage({ + id: 'interactive_dialog.error.after_max_date', + defaultMessage: 'Selected time is after the maximum allowed date.', + }); + } + } + return null; } diff --git a/webapp/channels/src/utils/date_utils.test.ts b/webapp/channels/src/utils/date_utils.test.ts index 8613b985e1f..136e7aaacf8 100644 --- a/webapp/channels/src/utils/date_utils.test.ts +++ b/webapp/channels/src/utils/date_utils.test.ts @@ -168,9 +168,9 @@ describe('date_utils', () => { expect(result).toBe('2025-01-22'); }); - it('should not resolve +1H (hours not supported)', () => { + it('should resolve +1H to a date string', () => { const result = resolveRelativeDate('+1H', testTimezone); - expect(result).toBe('+1H'); + expect(result).toBe('2025-01-15'); }); it('should resolve dynamic patterns like +5d', () => { @@ -209,12 +209,34 @@ describe('date_utils', () => { }); it('should still handle relative dates normally', () => { - // These should work exactly as before expect(stringToMoment('today')?.isValid()).toBe(true); expect(stringToMoment('+7d')?.isValid()).toBe(true); expect(stringToMoment('-2w')?.isValid()).toBe(true); }); + it('should resolve sub-day relative patterns (H/M/S)', () => { + // System time: 2025-01-15T10:00:00.000Z = 05:00 EST + const result = stringToMoment('+2H', testTimezone); + expect(result).toBeTruthy(); + expect(result!.tz(testTimezone).hour()).toBe(7); // 05:00 + 2H = 07:00 EST + expect(result!.tz(testTimezone).second()).toBe(0); + + const result30M = stringToMoment('+30M', testTimezone); + expect(result30M).toBeTruthy(); + expect(result30M!.tz(testTimezone).hour()).toBe(5); + expect(result30M!.tz(testTimezone).minute()).toBe(30); + + const result90S = stringToMoment('+90S', testTimezone); + expect(result90S).toBeTruthy(); + expect(result90S!.tz(testTimezone).hour()).toBe(5); + expect(result90S!.tz(testTimezone).minute()).toBe(1); + }); + + it('should reject case-insensitive variants of sub-day units', () => { + expect(stringToMoment('+1h', testTimezone)).toBeNull(); // lowercase h + expect(stringToMoment('+1s', testTimezone)).toBeNull(); // lowercase s + }); + it('should accept any valid ISO format', () => { // parseISO should accept various ISO formats expect(stringToMoment('2025-01-15')?.isValid()).toBe(true); // Date only diff --git a/webapp/channels/src/utils/date_utils.ts b/webapp/channels/src/utils/date_utils.ts index 5b8e7a7e05f..328983731bd 100644 --- a/webapp/channels/src/utils/date_utils.ts +++ b/webapp/channels/src/utils/date_utils.ts @@ -106,28 +106,30 @@ function resolveRelativeDateToMoment(dateStr: string, timezone?: string): Moment return now.subtract(1, 'day').startOf('day'); default: { - // Handle dynamic patterns like "+5d", "+2w", "+1m" - const dynamicMatch = dateStr.match(/^([+-]\d{1,4})([dwm])$/i); + // Handle dynamic patterns like "+5d", "+2w", "+1m", "+2H", "+30M", "+90S" + // Case-sensitive: d=days, w=weeks, m=months, H=hours, M=minutes, S=seconds + const dynamicMatch = dateStr.match(/^([+-]\d{1,3})([dwmHMS])$/); if (dynamicMatch) { const [, amount, unit] = dynamicMatch; const value = parseInt(amount, 10); - if (Math.abs(value) > 9999) { + if (Math.abs(value) > 999) { return null; } - let momentUnit: moment.unitOfTime.DurationConstructor; - - switch (unit.toLowerCase()) { + switch (unit) { case 'd': - momentUnit = 'day'; - return now.add(value, momentUnit).startOf('day'); + return now.add(value, 'day').startOf('day'); case 'w': - momentUnit = 'week'; - return now.add(value, momentUnit).startOf('day'); + return now.add(value, 'week').startOf('day'); case 'm': - momentUnit = 'month'; - return now.add(value, momentUnit).startOf('day'); + return now.add(value, 'month').startOf('day'); + case 'H': + return now.add(value, 'hour'); + case 'M': + return now.add(value, 'minute'); + case 'S': + return now.add(value, 'second'); default: return null; }