mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-26 21:27:40 -05:00
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:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
+18
-11
@@ -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={{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user