mattermost-mobile-upstream/app/utils/date_utils.ts
Scott Bishel c6b9170d4f
Add date/datetime field support with timezone features for Apps Forms (#9521)
* Add multiselect, dynamic select, multiform, and field refresh support for Interactive Dialogs

This PR adds comprehensive support for advanced interactive dialog features through the DialogRouter component, which routes between legacy InteractiveDialog and modern AppsForm implementations based on the FeatureFlagInteractiveDialogAppsForm feature flag.

Features Added:
- Multiselect support for select fields (users, channels, static options)
- Dynamic select fields with server-side lookup and search
- Multiform/multistep dialogs with state preservation across steps
- Field refresh functionality to update form fields based on selections

Core Components:
- DialogRouter: Routes between legacy InteractiveDialog and AppsForm based on feature flag
- InteractiveDialogAdapter: Converts between dialog formats and handles submission/lookup
- dialog_conversion utilities: Convert DialogElement to AppField format
- Enhanced autocomplete_selector with multiselect support
- Integration selector improvements for multiselect workflows

Technical Implementation:
- Follows webapp DialogRouter pattern (PR #31821)
- Uses WeakMap caching for expensive form conversions
- Proper error handling and validation for all field types
- Comprehensive unit test coverage for conversion logic

Bug Fixes:
- Fix dynamic select to fetch initial options with empty search input
- Fix multiselect value handling in dialog submission
- Add proper multiselect support in dialog-to-form conversion

Note: Changes only affect AppsForm path. Legacy InteractiveDialog remains unchanged and will only support basic features (no multiselect/dynamic/multiform).

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Add E2E tests and test infrastructure for interactive dialog features

E2E Tests Added (18 tests, all passing in ~10 minutes):
- MM-T4976: Multiselect fields (users, channels, options)
- MM-T4977: Dynamic select fields with search
- MM-T4980: Multistep dialog progression
- MM-T4981: Multistep dialog cancellation
- MM-T4983: Field refresh basic interaction
- MM-T4986: Field refresh changes and cancellation
- Plus 12 existing tests for basic dialogs, text fields, boolean fields

Test Infrastructure:
- Add interactive_dialog_test_helper with setup utilities
- Add webhook server support for dynamic plugin installation
- Enhanced integration_selector screen helpers for multiselect testing
- Add helper functions for user/channel selection

Test Optimizations:
- Replace arbitrary 2s waits with waitFor() for dynamic search results
- Reduce buffer waits from 500ms to 300ms where appropriate
- Add swipe gesture in ensureDialogClosed() to reveal posts hidden behind keyboard
- Tests optimized for speed while maintaining reliability

Bug Fixes in Tests:
- Fix MM-T4976 to use selectUser() helper instead of incorrect test data
- Remove multiform code incorrectly merged into MM-T4977 dynamic select test
- Add proper cleanup between tests to prevent state accumulation

All tests verified passing multiple times with proper synchronization.

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Fix integration selector tap handling on Android and update E2E tests

Bug Fix - Integration Selector:
- Replace KeyboardAwareFlatList with regular FlatList to fix tap handling on Android API 34+
- Add platform-specific keyboard dismiss behavior (onScrollBeginDrag for Android, on-drag for iOS)
- Follows the same pattern as invite/selection and user_list components

Issue: KeyboardAwareFlatList prevents taps on search results after typing in the search field on Android API 34+. Users could not select dynamic options after searching, making dynamic select fields unusable on Android.

E2E Test Updates:
- Add waitFor() with 3s timeout instead of immediate expect for dynamic field buttons
- Add 300ms buffer wait after selecting first dynamic option before checking for second field
- Ensures proper timing for dialog re-render after integration selector closes

Testing:
- All 18 E2E tests passing locally on Android Pixel 4 XL API 34 (~8.4 minutes)
- Verified dynamic select, multiselect, multiform, and field refresh functionality

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Address code review feedback for interactive dialog improvements

- Move API call to client layer: Added lookupInteractiveDialog method to client mixin following ClientMix pattern
- Extract error message function: Created getSubmissionErrorMessage helper to improve readability
- Simplify nullish coalescing: Use ?? operator instead of ternary for notify_on_cancel
- Add error logging: Added logDebug in autocomplete selector and performLookupCall catch blocks
- Improve field.name handling: Added early return check and removed unnecessary fallback
- Remove type cast: Use object spread instead of 'as any' for refresh submission
- Simplify refresh logic: Removed verbose debug logs from dialog conversion
- Revert PROFILE_CHUNK_SIZE: Changed back from 10 to 100 (unintentional change)
- Revert test config: Changed serverOneUrl back to localhost (unintentional change)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Add date/datetime field support with timezone features

  Core Features:
  - Add DATE and DATETIME field types for Apps Forms
  - User timezone and military time preferences via database observables
  - Relative date support (today, tomorrow, +5d, -2w, +1m)
  - Location timezone override with visual indicator (🌍 Times in GMT/PST)
  - Min/max date constraints with relative date resolution
  - Configurable time intervals (iOS: 1-30 min, fallback 60→30)
  - Required field validation prevents empty submission
  - allowPastDates logic (defaults to false, auto-calculated from min_date)

  Implementation:
  - DateTimeSelector component with timezone-aware display
  - parseDateInTimezone utility for proper date parsing
  - Dialog to AppForm conversion preserves datetime_config
  - Proper UTC conversion regardless of display timezone
  - Fix date off-by-one errors from timezone conversion
  - Fix field label overlap with flexbox layout

  Type Definitions:
  - Add min_date, max_date, time_interval, datetime_config to AppField
  - Define DateTimeConfig (time_interval, location_timezone, is_range, etc)
  - Update DialogElement and InteractiveDialogElementType

  Testing:
  - 52 unit tests passing (date utilities, validation, integration)
  - 7 E2E tests passing (field display, validation, timezone, UTC conversion)
  - Comprehensive coverage for relative dates, timezone parsing, validation

  Architecture:
  - withObservables HOC pattern for reactive user preferences
  - Only modifies apps_form (not deprecated interactive_dialog)
  - Clean separation of utilities, components, and observables
  - Follows existing patterns from custom_status/scheduled_posts

* move performDynamicLookup and use currentConfig

* Fix code review findings for datetime field support

- Hoist useMemo/hook calls out of switch-case (Rules of Hooks fix)
- Wrap AppsFormFieldComponent with React.memo
- Extract dateTimeStyles to makeStyleSheetFromTheme at module scope
- i18n: replace hardcoded "Times in", "Select Date", "Select Time" strings
- Remove spurious handleChange calls from picker toggle buttons
- Add .clone() to prevent moment mutations in resolveRelativeDate and getDateValue
- Fix test: remove private _d field access, assert no-call on toggle
- Fix getStyleSheet return syntax in DateTimeSelector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Align mobile date/datetime with webapp and migrate E2E tests

Date/DateTime parity with webapp:
- Change default time interval from 30 to 60 minutes
- Add time offset support (+H, +M, +S) to resolveRelativeDate
- Add manual time entry with parseTimeString for allow_manual_time_entry
- Auto-populate required datetime fields with rounded current time
- Remove unused range fields from DateTimeConfig type
- Extract DEFAULT_TIME_INTERVAL_MINUTES to shared constant
- Use effectiveInterval for consistent iOS picker/rounding alignment
- Wrap handlers in useCallback, use typography() and changeOpacity()
- Add logWarning for +M disambiguation and null date parse failures
- Internationalize date-time separator string

E2E test infrastructure:
- Migrate all dialog tests from demo plugin to test plugin
- Replace DemoPlugin with TestPlugin constant
- Remove duplicate MM-T4986 test and demo-plugin activation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix line spacing nit

* remove file, update alphabetically

* Fix code review findings for datetime field support

Bug fixes:
- Fix relative time offsets (+2H, +30M) being overwritten with current
  time in getDateValue — skip time replacement for HMS offsets
- Handle Android picker dismiss (event.type === 'dismissed') to prevent
  silently filling fields when user cancels
- Parse minDate/maxDate with field timezone via parseDateInTimezone
  to avoid off-by-one at timezone day boundaries
- Use field's location_timezone in initValues for correct auto-defaults
- Fix parseDateInTimezone to use timezone for date-only strings

Improvements:
- Add database teardown in DateTimeSelector test (afterAll cleanup)
- Fail fast in apiGetLatestPluginVersion instead of wrong fallback
- Stronger E2E assertions: validation error text in MM-T2530B,
  ISO datetime pattern check in MM-T2530F
- Extract ISO_DATETIME_PATTERN constant for E2E test reuse
- Use typography() in apps_form_field.tsx stylesheets
- Internationalize hardcoded 'at' date-time separator

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix by.text() RegExp usage in MM-T2530G timezone test

Detox's by.text() only accepts strings, not RegExp. Use API to fetch
the submission post and validate ISO datetime pattern in JavaScript,
matching the approach used in MM-T2530F.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address CodeRabbit R2 findings in E2E tests

- Enforce UTC suffix (Z or ±HH:MM) in ISO_DATETIME_PATTERN
- Make required-field validation assertion mandatory (remove try/catch)
- Fail fast when field refresh element not found (remove silent fallback)
- Remove commented-out pluginInstallAndEnable dead code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add unit tests for datetime business logic

Cover parseTimeString, initValues datetime auto-population,
getDateValue/isTimeOffset, and datetime_config preservation in
dialog_conversion — areas flagged as uncovered in PR test check.
Export the four helpers from their modules so tests can exercise
them directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pin demo plugin install via fixture upload + add datetime E2E tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Auto-download demo plugin tarball into fixtures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Configure demo plugin user/channel/lastname for fresh-install path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add datetime E2E tests and isolate iOS 26 keyboard contamination

Adds MM-T2530A-H covering date/datetime field rendering, validation,
picker interaction, defaults, UTC submit, timezone indicator, and manual
time entry. Manual entry placed last (T2530H) since the TextInput
keyboard cycle leaves animation state on iOS 26 +
react-native-keyboard-controller that breaks subsequent dialog tests.

Skips T4983 with TODO referencing the same iOS 26 keyboard contamination
(field-refresh dialog with text inputs poisons RCTRefreshControl with
progressViewOffset NaN). Refines selectUser/selectChannel helpers to
target by entity ID for when the iOS 26 + Detox UserItem TouchableOpacity
hit-test regression is resolved upstream and skipped tests can be
re-enabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix lint and update tests for updates to main

* fix bad import

* updates for code review, move/rename files

* Restore UserItem testID on the touchable (from reverted #9790)

  #9790 (Rainforest→Detox migration) was reverted in #9834, which dropped
  this fix. On iOS 26, Detox synthetic taps don't propagate from the inner
  testID-bearing View to the parent TouchableOpacity, so integration-selector
  user rows weren't selectable in E2E. Move the testID onto the TouchableOpacity
  (as CustomListRow already does) to make the row tappable again.

* Update user_list snapshot for UserItem testID move

* updates from main merge, review fixes

* fix e2etests

* fix bad merge

* fix failing test caused by bad merge

---------

Co-authored-by: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
2026-07-16 08:09:47 -06:00

158 lines
5.9 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment, {type Moment} from 'moment-timezone';
import {getCurrentMomentForTimezone} from './helpers';
import {logWarning} from './log';
/**
* Date reference constants for relative dates
*/
enum DateReference {
TODAY = 'today',
TOMORROW = 'tomorrow',
YESTERDAY = 'yesterday',
}
/**
* Resolves relative date expressions to date or datetime strings
* Supports:
* - Named references: "today", "tomorrow", "yesterday"
* - Date offsets: "+5d", "-3d", "+2w", "+1m" (days, weeks, months) → YYYY-MM-DD
* - Time offsets: "+2H", "+30M", "+90S" (hours, minutes, seconds) → ISO 8601 datetime
*
* @param dateStr - The date string to resolve (can be relative or absolute)
* @param timezone - Optional timezone for "today" reference (defaults to UTC)
* @returns ISO date string (YYYY-MM-DD) for date offsets, ISO 8601 datetime for time offsets, or original string if not relative
*
* @example
* resolveRelativeDate("today") // "2026-01-15"
* resolveRelativeDate("+5d") // "2026-01-20"
* resolveRelativeDate("+2H") // "2026-01-15T14:00:00Z"
* resolveRelativeDate("+30M") // "2026-01-15T12:30:00Z"
* resolveRelativeDate("2026-01-15") // "2026-01-15" (unchanged)
*/
export function resolveRelativeDate(dateStr: string, timezone?: string): string {
if (!dateStr) {
return dateStr;
}
const now = timezone ? getCurrentMomentForTimezone(timezone) : moment.utc();
// Handle named date references
switch (dateStr.toLowerCase()) {
case DateReference.TODAY:
return now.clone().startOf('day').format('YYYY-MM-DD');
case DateReference.TOMORROW:
return now.clone().add(1, 'day').startOf('day').format('YYYY-MM-DD');
case DateReference.YESTERDAY:
return now.clone().subtract(1, 'day').startOf('day').format('YYYY-MM-DD');
}
// Handle dynamic offset patterns: +5d, -3d, +2w, +1m, +2H, +30M, +90S
const match = dateStr.match(/^([+-]\d{1,4})([dwmHMS])$/);
if (match) {
const [, amount, unit] = match;
const value = parseInt(amount, 10);
// Time units (uppercase): return full ISO datetime
// Note: uppercase M = minutes, lowercase m = months (case-sensitive)
const timeUnitMap: {[key: string]: moment.unitOfTime.DurationConstructor} = {
H: 'hour',
M: 'minute',
S: 'second',
};
if (unit in timeUnitMap) {
if (unit === 'M') {
logWarning('[resolveRelativeDate]', `"${dateStr}" resolved as ${value} minute(s). Use lowercase "m" for months.`);
}
return now.clone().add(value, timeUnitMap[unit]).toISOString();
}
// Date units (lowercase): return date-only string
const dateUnitMap: {[key: string]: moment.unitOfTime.DurationConstructor} = {
d: 'day',
w: 'week',
m: 'month',
};
return now.clone().add(value, dateUnitMap[unit]).startOf('day').format('YYYY-MM-DD');
}
// Not a relative date, return as-is
return dateStr;
}
/**
* Checks if a string is a relative date expression
*/
export function isRelativeDate(dateStr: string): boolean {
if (!dateStr) {
return false;
}
const lower = dateStr.toLowerCase();
if ([DateReference.TODAY, DateReference.TOMORROW, DateReference.YESTERDAY].includes(lower as DateReference)) {
return true;
}
return /^[+-]\d{1,4}[dwmHMS]$/.test(dateStr);
}
/**
* Parses a date string with proper timezone handling
* For date-only strings (YYYY-MM-DD), parses in the specified timezone
* For datetime strings with time/UTC indicator, parses as UTC then converts
*
* @param value - Date string to parse
* @param timezone - IANA timezone to use for date-only strings
* @returns Moment object or null if invalid
*
* @example
* parseDateInTimezone("2026-01-15", "America/New_York") // Parsed as midnight in New York
* parseDateInTimezone("2026-01-15T14:30:00Z", "America/New_York") // Parsed as UTC, displayed in New York
*/
export function parseDateInTimezone(value: string | null | undefined, timezone?: string | null | undefined): moment.Moment | null {
if (!value) {
return null;
}
// Check if this is a date-only string (YYYY-MM-DD format)
const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/;
const isDateOnly = dateOnlyPattern.test(value);
if (isDateOnly) {
// For date-only strings, parse in the specified timezone at midnight
// to avoid off-by-one errors from device/UTC timezone mismatch
const parsed = timezone ? moment.tz(value, 'YYYY-MM-DD', timezone) : moment(value, 'YYYY-MM-DD');
return parsed.isValid() ? parsed : null;
}
// For datetime strings, use timezone
const parsed = timezone ? moment.tz(value, timezone) : moment(value);
return parsed.isValid() ? parsed : null;
}
export const isTimeOffset = (dateStr: string): boolean => /^[+-]\d{1,4}[HMS]$/.test(dateStr);
export const getDateValue = (value: AppFormValue, timezone?: string, isDateTime = false): Moment | undefined => {
if (typeof value === 'string' && value) {
// Resolve relative dates FIRST (today, +1d, etc.)
const resolvedValue = resolveRelativeDate(value, timezone);
// Then parse the resolved date
const parsed = parseDateInTimezone(resolvedValue, timezone);
// For datetime fields with date-only relative values (today, +1d, +2w),
// set the time to current time. Skip for time offsets (+2H, +30M, +90S)
// which already have the correct timestamp from resolveRelativeDate.
if (isDateTime && parsed && value !== resolvedValue && !isTimeOffset(value)) {
const currentTime = getCurrentMomentForTimezone(timezone || null);
return parsed.clone().hour(currentTime.hour()).minute(currentTime.minute()).second(0);
}
return parsed || undefined;
}
return undefined;
};