mattermost-mobile-upstream/app/utils/integrations.test.ts
Mattermost Build bbf8a9ea15
Add date/datetime field support with timezone features for Apps Forms (#9521) (#9933)
* 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).



* 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.



* 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



* 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)



* 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



* 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



* 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



* 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.



* 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



* 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.



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



* Auto-download demo plugin tarball into fixtures



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



* 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.



* 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

---------



(cherry picked from commit c6b9170d4f)

Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-07-17 08:04:27 +03:00

391 lines
13 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {checkDialogElementForError, checkIfErrorsMatchElements, selectKeyboardType} from './integrations';
import type {IntlShape, MessageDescriptor} from 'react-intl';
function makeIntl(): IntlShape {
return {
formatMessage: jest.fn((descriptor: MessageDescriptor, values?: Record<string, unknown>) => {
if (values && Object.keys(values).length > 0) {
let msg = descriptor.defaultMessage as string;
for (const [k, v] of Object.entries(values)) {
msg = msg.replace(`{${k}}`, String(v));
}
return msg;
}
return descriptor.defaultMessage as string;
}),
} as unknown as IntlShape;
}
describe('checkDialogElementForError', () => {
it('should return required error for empty required field', () => {
const elem: DialogElement = {
name: 'field1',
type: 'text',
optional: false,
display_name: '',
subtype: 'number',
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
};
expect(checkDialogElementForError(elem, '', makeIntl())).toBe('This field is required.');
});
it('should return too short error for text shorter than min_length', () => {
const elem: DialogElement = {
name: 'field1',
type: 'text',
min_length: 5,
display_name: '',
subtype: 'number',
default: '',
placeholder: '',
help_text: '',
optional: false,
max_length: 0,
data_source: '',
options: [],
};
expect(checkDialogElementForError(elem, '123', makeIntl())).toBe('Minimum input length is 5.');
});
it('should return bad email error for invalid email', () => {
const elem: DialogElement = {
name: 'field1',
type: 'text',
subtype: 'email',
display_name: '',
default: '',
placeholder: '',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: [],
};
expect(checkDialogElementForError(elem, 'invalidemail', makeIntl())).toBe('Must be a valid email address.');
});
it('should return bad number error for invalid number', () => {
const elem: DialogElement = {
name: 'field1',
type: 'text',
subtype: 'number',
display_name: '',
default: '',
placeholder: '',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: [],
};
expect(checkDialogElementForError(elem, 'notanumber', makeIntl())).toBe('Must be a number.');
});
it('should return bad URL error for invalid URL', () => {
const elem: DialogElement = {
name: 'field1',
type: 'text',
subtype: 'url',
display_name: '',
default: '',
placeholder: '',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: [],
};
expect(checkDialogElementForError(elem, 'invalidurl', makeIntl())).toBe('URL must include http:// or https://.');
});
it('should return invalid option error for invalid radio option', () => {
const elem: DialogElement = {
name: 'field1',
type: 'radio',
options: [{value: 'option1', text: ''}, {value: 'option2', text: ''}],
display_name: '',
subtype: 'number',
default: '',
placeholder: '',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
};
expect(checkDialogElementForError(elem, 'invalidoption', makeIntl())).toBe('Must be a valid option');
});
it('should return null for valid inputs', () => {
const elemText: DialogElement = {
name: 'field1',
type: 'text',
min_length: 3,
display_name: '',
subtype: 'password',
default: '',
placeholder: '',
help_text: '',
optional: false,
max_length: 0,
data_source: '',
options: [],
};
const elemEmail: DialogElement = {
name: 'field2',
type: 'text',
subtype: 'email',
display_name: '',
default: '',
placeholder: '',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: [],
};
const elemNumber: DialogElement = {
name: 'field3',
type: 'text',
subtype: 'number',
display_name: '',
default: '',
placeholder: '',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: [],
};
const elemURL: DialogElement = {
name: 'field4',
type: 'text',
subtype: 'url',
display_name: '',
default: '',
placeholder: '',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: [],
};
const elemRadio: DialogElement = {
name: 'field5',
type: 'radio',
options: [{value: 'option1', text: ''}, {value: 'option2', text: ''}],
display_name: '',
subtype: 'number',
default: '',
placeholder: '',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
};
const intl = makeIntl();
expect(checkDialogElementForError(elemText, 'valid', intl)).toBeNull();
expect(checkDialogElementForError(elemEmail, 'email@example.com', intl)).toBeNull();
expect(checkDialogElementForError(elemNumber, '123', intl)).toBeNull();
expect(checkDialogElementForError(elemURL, 'http://example.com', intl)).toBeNull();
expect(checkDialogElementForError(elemRadio, 'option1', intl)).toBeNull();
});
describe('multiselect SELECT validation', () => {
const multiselectElement: DialogElement = {
name: 'multiselect_field',
type: 'select',
multiselect: true,
optional: false,
options: [
{text: 'Option A', value: 'optA'},
{text: 'Option B', value: 'optB'},
{text: 'Option C', value: 'optC'},
],
display_name: 'Multi Select Field',
placeholder: '',
help_text: '',
default: '',
min_length: 0,
max_length: 0,
data_source: '',
};
it('should validate multiselect values correctly', () => {
expect(checkDialogElementForError(multiselectElement, ['optA', 'optC'], makeIntl())).toBeNull();
});
it('should require at least one selection for required multiselect', () => {
expect(checkDialogElementForError(multiselectElement, [], makeIntl())).toBe('This field is required.');
});
it('should reject invalid options in multiselect array', () => {
expect(checkDialogElementForError(multiselectElement, ['optA', 'invalidOption'], makeIntl())).toBe('Must be a valid option');
});
it('should allow empty arrays for optional multiselect', () => {
const optionalElement = {...multiselectElement, optional: true};
expect(checkDialogElementForError(optionalElement, [], makeIntl())).toBeNull();
});
it('should handle single valid option in multiselect', () => {
expect(checkDialogElementForError(multiselectElement, ['optB'], makeIntl())).toBeNull();
});
it('should handle all options selected', () => {
expect(checkDialogElementForError(multiselectElement, ['optA', 'optB', 'optC'], makeIntl())).toBeNull();
});
});
});
describe('checkIfErrorsMatchElements', () => {
it('should return false if no dialog elements', () => {
const errors = {field1: 'error'};
expect(checkIfErrorsMatchElements(errors)).toBe(false);
});
it('should return false if no dialog errors', () => {
expect(checkIfErrorsMatchElements()).toBe(false);
});
it('should return true if errors match elements', () => {
const elements: DialogElement[] = [{
name: 'field1',
type: 'text',
display_name: '',
subtype: 'number',
default: '',
placeholder: '',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: [],
}];
const errors = {field1: 'error'};
expect(checkIfErrorsMatchElements(errors, elements)).toBe(true);
});
it('should return false if errors do not match elements', () => {
const elements: DialogElement[] = [{
name: 'field1',
type: 'text',
display_name: '',
subtype: 'number',
default: '',
placeholder: '',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: [],
}];
const errors = {field2: 'error'};
expect(checkIfErrorsMatchElements(errors, elements)).toBe(false);
});
it('should return false if errors and elements are empty', () => {
expect(checkIfErrorsMatchElements({}, [])).toBe(false);
});
});
describe('selectKeyboardType', () => {
it('should return email-address for email subtype', () => {
expect(selectKeyboardType('email')).toBe('email-address');
});
it('should return numeric for number subtype', () => {
expect(selectKeyboardType('number')).toBe('numeric');
});
it('should return phone-pad for tel subtype', () => {
expect(selectKeyboardType('tel')).toBe('phone-pad');
});
it('should return url for url subtype', () => {
expect(selectKeyboardType('url')).toBe('url');
});
it('should return default for undefined subtype', () => {
expect(selectKeyboardType()).toBe('default');
});
it('should return default for unrecognized subtype', () => {
expect(selectKeyboardType('unrecognized')).toBe('default');
});
});
describe('checkDialogElementForError - date/datetime fields', () => {
const baseElement: DialogElement = {
name: 'test_date',
type: 'date',
display_name: 'Test Date',
subtype: undefined,
default: '',
placeholder: '',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: [],
};
test('returns required error for empty required date field', () => {
const result = checkDialogElementForError(baseElement, '', makeIntl());
expect(result).toBe('This field is required.');
});
test('returns required error for undefined required date field', () => {
const result = checkDialogElementForError(baseElement, undefined, makeIntl());
expect(result).toBe('This field is required.');
});
test('returns required error for null required date field', () => {
const result = checkDialogElementForError(baseElement, null, makeIntl());
expect(result).toBe('This field is required.');
});
test('returns no error for valid date value', () => {
const result = checkDialogElementForError(baseElement, '2026-02-12', makeIntl());
expect(result).toBeNull();
});
test('returns no error for optional empty date field', () => {
const optionalElement = {...baseElement, optional: true};
const result = checkDialogElementForError(optionalElement, '', makeIntl());
expect(result).toBeNull();
});
test('returns required error for empty required datetime field', () => {
const datetimeElement = {...baseElement, type: 'datetime' as InteractiveDialogElementType};
const result = checkDialogElementForError(datetimeElement, '', makeIntl());
expect(result).toBe('This field is required.');
});
test('returns no error for valid datetime value', () => {
const datetimeElement = {...baseElement, type: 'datetime' as InteractiveDialogElementType};
const result = checkDialogElementForError(datetimeElement, '2026-02-12T14:30:00Z', makeIntl());
expect(result).toBeNull();
});
});