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
This commit is contained in:
Adam Schildkraut
2026-03-26 17:19:15 -06:00
committed by GitHub
parent 0d01619585
commit 656a0248eb
16 changed files with 393 additions and 93 deletions
@@ -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) {
+4 -4
View File
@@ -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))
@@ -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)
})
}
+4
View File
@@ -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."
+25 -12
View File
@@ -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" {
+71 -5
View File
@@ -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)
})
}
@@ -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<Props, State> {
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[];
}
@@ -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<Props> = ({
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 (
<div className='apps-form-datetime-input'>
{showTimezoneIndicator && (
@@ -109,6 +114,8 @@ const AppsFormDateTimeField: React.FC<Props> = ({
allowPastDates={allowPastDates}
allowManualTimeEntry={allowManualTimeEntry}
setIsInteracting={setIsInteracting}
minDateTime={minDateTime}
maxDateTime={maxDateTime}
/>
</div>
);
@@ -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<TimeInputManualProps> = ({
@@ -120,6 +126,8 @@ const TimeInputManual: React.FC<TimeInputManualProps> = ({
timezone,
isMilitaryTime,
onTimeChange,
minDateTime,
maxDateTime,
}) => {
const {formatMessage} = useIntl();
const [timeInputValue, setTimeInputValue] = useState<string>('');
@@ -173,10 +181,17 @@ const TimeInputManual: React.FC<TimeInputManualProps> = ({
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<HTMLInputElement>) => {
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<Props> = ({
@@ -226,6 +243,8 @@ const DateTimeInputContainer: React.FC<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
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<Props> = ({
<i className='icon-clock-outline'/>
);
// 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<Props> = ({
timezone={timezone}
isMilitaryTime={isMilitaryTime}
onTimeChange={handleTimeChange}
minDateTime={minDateTime}
maxDateTime={maxDateTime}
/>
) : (
<Menu.Container
@@ -7,6 +7,7 @@ import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {interactiveDialogAppsFormEnabled} from 'mattermost-redux/selectors/entities/interactive_dialog';
import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {submitInteractiveDialog, lookupInteractiveDialog} from 'actions/integration_actions';
import {getEmojiMap} from 'selectors/emojis';
@@ -41,6 +42,7 @@ function mapStateToProps(state: GlobalState) {
emojiMap,
isAppsFormEnabled,
hasUrl: Boolean(data.url),
timezone: getCurrentTimezone(state) || undefined,
};
}
@@ -46,6 +46,7 @@ interface Props extends WrappedComponentProps {
// Enhanced functionality
sourceUrl?: string; // Optional URL for form refresh functionality
conversionOptions?: Partial<ConversionOptions>;
timezone?: string;
// Required actions
actions: {
@@ -676,6 +677,7 @@ class InteractiveDialogAdapter extends React.PureComponent<Props> {
<AppsFormContainer
form={form}
appContext={context}
timezone={this.props.timezone}
onExited={this.props.onExited || (() => {})}
onHide={this.cancelAdapter}
actions={{
+3 -1
View File
@@ -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}.",
@@ -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();
});
});
});
@@ -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;
}
+25 -3
View File
@@ -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
+14 -12
View File
@@ -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;
}