mattermost-mobile-upstream/app/utils/dialog_conversion.test.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

1093 lines
38 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
convertAppFormValuesToDialogSubmission,
convertDialogElementToAppField,
convertDialogToAppForm,
} from './dialog_conversion';
import {DialogElementTypes} from './dialog_utils';
describe('dialog_conversion', () => {
describe('convertAppFormValuesToDialogSubmission', () => {
const mockElements: DialogElement[] = [
{
name: 'text_field',
type: DialogElementTypes.TEXT,
display_name: 'Text Field',
optional: false,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
},
{
name: 'number_field',
type: DialogElementTypes.TEXT,
subtype: 'number',
display_name: 'Number Field',
optional: true,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
},
{
name: 'select_field',
type: DialogElementTypes.SELECT,
display_name: 'Select Field',
optional: true,
options: [
{value: 'option1', text: 'Option 1'},
{value: 'option2', text: 'Option 2'},
],
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
},
{
name: 'radio_field',
type: DialogElementTypes.RADIO,
display_name: 'Radio Field',
optional: false,
options: [
{value: 'radio1', text: 'Radio 1'},
{value: 'radio2', text: 'Radio 2'},
],
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
},
{
name: 'bool_field',
type: DialogElementTypes.BOOL,
display_name: 'Boolean Field',
optional: true,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
},
];
it('should convert text field values correctly', () => {
const values: AppFormValues = {
text_field: 'user input text',
number_field: '123',
};
const result = convertAppFormValuesToDialogSubmission(values, mockElements);
expect(result.submission).toEqual({
text_field: 'user input text',
number_field: 123, // Should be converted to number
});
expect(result.errors).toEqual([]);
});
it('should handle empty number fields by omitting them', () => {
const values: AppFormValues = {
text_field: 'user input text',
number_field: '', // Empty number field
};
const result = convertAppFormValuesToDialogSubmission(values, mockElements);
expect(result.submission).toEqual({
text_field: 'user input text',
// number_field should be omitted
});
expect(result.errors).toEqual([]);
});
it('should handle null/undefined number fields by omitting them', () => {
const values: AppFormValues = {
text_field: 'user input text',
number_field: null,
};
const result = convertAppFormValuesToDialogSubmission(values, mockElements);
expect(result.submission).toEqual({
text_field: 'user input text',
// number_field should be omitted
});
expect(result.errors).toEqual([]);
});
it('should handle invalid number fields as strings', () => {
const values: AppFormValues = {
text_field: 'user input text',
number_field: 'not a number',
};
const result = convertAppFormValuesToDialogSubmission(values, mockElements);
expect(result.submission).toEqual({
text_field: 'user input text',
number_field: 'not a number', // Should remain as string
});
expect(result.errors).toEqual([]);
});
it('should convert AppSelectOption objects to values', () => {
const values: AppFormValues = {
select_field: {label: 'Option 1', value: 'option1'},
radio_field: {label: 'Radio 2', value: 'radio2'},
};
const result = convertAppFormValuesToDialogSubmission(values, mockElements);
expect(result.submission).toEqual({
select_field: 'option1',
radio_field: 'radio2',
});
expect(result.errors).toEqual([]);
});
it('should handle string values for select/radio fields', () => {
const values: AppFormValues = {
select_field: 'option2',
radio_field: 'radio1',
};
const result = convertAppFormValuesToDialogSubmission(values, mockElements);
expect(result.submission).toEqual({
select_field: 'option2',
radio_field: 'radio1',
});
expect(result.errors).toEqual([]);
});
it('should convert boolean values correctly', () => {
const values: AppFormValues = {
bool_field: true,
};
const result = convertAppFormValuesToDialogSubmission(values, mockElements);
expect(result.submission).toEqual({
bool_field: true,
});
expect(result.errors).toEqual([]);
});
it('should handle falsy boolean values', () => {
const values: AppFormValues = {
bool_field: false,
};
const result = convertAppFormValuesToDialogSubmission(values, mockElements);
expect(result.submission).toEqual({
bool_field: false,
});
expect(result.errors).toEqual([]);
});
it('should handle unknown field types as strings', () => {
const elementsWithUnknown: DialogElement[] = [
{
name: 'unknown_field',
type: 'unknown_type' as any,
display_name: 'Unknown Field',
optional: true,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
},
];
const values: AppFormValues = {
unknown_field: 'some value',
};
const result = convertAppFormValuesToDialogSubmission(values, elementsWithUnknown);
expect(result.submission).toEqual({
unknown_field: 'some value',
});
expect(result.errors).toEqual([]);
});
it('should report errors for fields not found in elements', () => {
const values: AppFormValues = {
text_field: 'valid field',
nonexistent_field: 'invalid field',
};
const result = convertAppFormValuesToDialogSubmission(values, mockElements);
expect(result.submission).toEqual({
text_field: 'valid field',
});
expect(result.errors).toEqual(['Field nonexistent_field not found in dialog elements']);
});
it('should handle empty values object', () => {
const result = convertAppFormValuesToDialogSubmission({}, mockElements);
expect(result.submission).toEqual({});
expect(result.errors).toEqual([]);
});
it('should handle empty elements array', () => {
const values: AppFormValues = {
some_field: 'some value',
};
const result = convertAppFormValuesToDialogSubmission(values, []);
expect(result.submission).toEqual({});
expect(result.errors).toEqual(['Field some_field not found in dialog elements']);
});
it('should handle multiselect AppSelectOption arrays', () => {
const mockMultiselectElement: DialogElement = {
name: 'multiselect_field',
type: DialogElementTypes.SELECT,
multiselect: true,
display_name: 'Multi Select',
optional: false,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [
{value: 'opt1', text: 'Option 1'},
{value: 'opt2', text: 'Option 2'},
{value: 'opt3', text: 'Option 3'},
],
};
const values: AppFormValues = {
multiselect_field: [
{label: 'Option 1', value: 'opt1'},
{label: 'Option 3', value: 'opt3'},
],
};
const result = convertAppFormValuesToDialogSubmission(values, [mockMultiselectElement]);
expect(result.submission).toEqual({
multiselect_field: 'opt1,opt3',
});
expect(result.errors).toEqual([]);
});
it('should handle multiselect string arrays', () => {
const mockMultiselectElement: DialogElement = {
name: 'multiselect_field',
type: DialogElementTypes.SELECT,
multiselect: true,
display_name: 'Multi Select',
optional: false,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [
{value: 'opt1', text: 'Option 1'},
{value: 'opt2', text: 'Option 2'},
],
};
const values: AppFormValues = {
multiselect_field: ['opt1', 'opt2'] as any,
};
const result = convertAppFormValuesToDialogSubmission(values, [mockMultiselectElement]);
expect(result.submission).toEqual({
multiselect_field: 'opt1,opt2',
});
expect(result.errors).toEqual([]);
});
it('should handle empty multiselect arrays', () => {
const mockMultiselectElement: DialogElement = {
name: 'multiselect_field',
type: DialogElementTypes.SELECT,
multiselect: true,
display_name: 'Multi Select',
optional: true,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
};
const values: AppFormValues = {
multiselect_field: [],
};
const result = convertAppFormValuesToDialogSubmission(values, [mockMultiselectElement]);
expect(result.submission).toEqual({
multiselect_field: '',
});
expect(result.errors).toEqual([]);
});
it('should not affect single select with multiselect=false', () => {
const mockSingleSelectElement: DialogElement = {
name: 'single_select',
type: DialogElementTypes.SELECT,
multiselect: false,
display_name: 'Single Select',
optional: false,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [
{value: 'opt1', text: 'Option 1'},
],
};
const values: AppFormValues = {
single_select: {label: 'Option 1', value: 'opt1'},
};
const result = convertAppFormValuesToDialogSubmission(values, [mockSingleSelectElement]);
expect(result.submission).toEqual({
single_select: 'opt1',
});
expect(result.errors).toEqual([]);
});
});
describe('convertDialogElementToAppField', () => {
it('should convert text element correctly', () => {
const element: DialogElement = {
name: 'text_field',
type: DialogElementTypes.TEXT,
display_name: 'Text Field',
help_text: 'Enter some text',
placeholder: 'Type here',
default: 'default value',
optional: false,
min_length: 5,
max_length: 100,
data_source: '',
options: [],
};
const result = convertDialogElementToAppField(element);
expect(result).toEqual({
name: 'text_field',
type: 'text',
is_required: true,
label: 'Text Field',
description: 'Enter some text',
position: 0,
hint: 'Type here',
value: 'default value',
min_length: 5,
max_length: 100,
});
});
it('should convert textarea element correctly', () => {
const element: DialogElement = {
name: 'textarea_field',
type: DialogElementTypes.TEXTAREA,
display_name: 'Textarea Field',
help_text: 'Enter multiple lines',
optional: true,
min_length: 10,
max_length: 500,
default: '',
placeholder: '',
data_source: '',
options: [],
};
const result = convertDialogElementToAppField(element);
expect(result).toEqual({
name: 'textarea_field',
type: 'text',
is_required: false,
label: 'Textarea Field',
description: 'Enter multiple lines',
position: 0,
min_length: 10,
max_length: 500,
});
});
it('should convert select element correctly', () => {
const element: DialogElement = {
name: 'select_field',
type: DialogElementTypes.SELECT,
display_name: 'Select Field',
help_text: 'Choose an option',
optional: true,
options: [
{value: 'opt1', text: 'Option 1'},
{value: 'opt2', text: 'Option 2'},
],
default: 'opt1',
placeholder: '',
min_length: 0,
max_length: 0,
data_source: '',
};
const result = convertDialogElementToAppField(element);
expect(result).toEqual({
name: 'select_field',
type: 'static_select',
is_required: false,
label: 'Select Field',
description: 'Choose an option',
position: 0,
value: 'opt1',
options: [
{label: 'Option 1', value: 'opt1'},
{label: 'Option 2', value: 'opt2'},
],
});
});
it('should convert multiselect element correctly', () => {
const element: DialogElement = {
name: 'multiselect_field',
type: DialogElementTypes.SELECT,
display_name: 'Multiselect Field',
help_text: 'Choose multiple options',
optional: false,
multiselect: true,
options: [
{value: 'opt1', text: 'Option 1'},
{value: 'opt2', text: 'Option 2'},
{value: 'opt3', text: 'Option 3'},
],
default: 'opt1,opt3',
placeholder: '',
min_length: 0,
max_length: 0,
data_source: '',
};
const result = convertDialogElementToAppField(element);
expect(result).toEqual({
name: 'multiselect_field',
type: 'static_select',
is_required: true,
label: 'Multiselect Field',
description: 'Choose multiple options',
position: 0,
value: 'opt1,opt3',
multiselect: true,
options: [
{label: 'Option 1', value: 'opt1'},
{label: 'Option 2', value: 'opt2'},
{label: 'Option 3', value: 'opt3'},
],
});
});
it('should convert radio element correctly', () => {
const element: DialogElement = {
name: 'radio_field',
type: DialogElementTypes.RADIO,
display_name: 'Radio Field',
help_text: 'Choose one',
optional: false,
options: [
{value: 'radio1', text: 'Radio Option 1'},
{value: 'radio2', text: 'Radio Option 2'},
],
default: 'radio2',
placeholder: '',
min_length: 0,
max_length: 0,
data_source: '',
};
const result = convertDialogElementToAppField(element);
expect(result).toEqual({
name: 'radio_field',
type: 'radio',
is_required: true,
label: 'Radio Field',
description: 'Choose one',
position: 0,
value: 'radio2',
options: [
{label: 'Radio Option 1', value: 'radio1'},
{label: 'Radio Option 2', value: 'radio2'},
],
});
});
it('should convert boolean element correctly', () => {
const element: DialogElement = {
name: 'bool_field',
type: DialogElementTypes.BOOL,
display_name: 'Boolean Field',
help_text: 'Check if applicable',
optional: true,
default: 'true',
placeholder: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
};
const result = convertDialogElementToAppField(element);
expect(result).toEqual({
name: 'bool_field',
type: 'bool',
is_required: false,
label: 'Boolean Field',
description: 'Check if applicable',
position: 0,
value: 'true',
});
});
it('should handle elements without options', () => {
const element: DialogElement = {
name: 'text_field',
type: DialogElementTypes.TEXT,
display_name: 'Text Field',
optional: false,
options: [],
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
};
const result = convertDialogElementToAppField(element);
expect(result.options).toBeUndefined();
expect(result.type).toBe('text');
});
it('should handle empty options array', () => {
const element: DialogElement = {
name: 'select_field',
type: DialogElementTypes.SELECT,
display_name: 'Select Field',
optional: false,
options: [],
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
};
const result = convertDialogElementToAppField(element);
expect(result.options).toEqual([]);
});
it('should not add hint when placeholder is empty', () => {
const element: DialogElement = {
name: 'text_field',
type: DialogElementTypes.TEXT,
display_name: 'Text Field',
optional: false,
placeholder: '',
default: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
};
const result = convertDialogElementToAppField(element);
expect(result.hint).toBeUndefined();
});
it('should not add value when default is empty', () => {
const element: DialogElement = {
name: 'text_field',
type: DialogElementTypes.TEXT,
display_name: 'Text Field',
optional: false,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
};
const result = convertDialogElementToAppField(element);
expect(result.value).toBeUndefined();
});
describe('datetime configuration preservation', () => {
const datetimeConfig = {
time_interval: 15,
location_timezone: 'America/New_York',
allow_manual_time_entry: true,
};
it('should preserve min_date, max_date, time_interval, and datetime_config for date elements', () => {
const element: DialogElement = {
name: 'date_field',
type: DialogElementTypes.DATE,
display_name: 'Date Field',
optional: false,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
min_date: '2026-01-01',
max_date: '2026-12-31',
time_interval: 30,
datetime_config: datetimeConfig,
};
const result = convertDialogElementToAppField(element);
expect(result.min_date).toBe('2026-01-01');
expect(result.max_date).toBe('2026-12-31');
expect(result.time_interval).toBe(30);
expect(result.datetime_config).toEqual(datetimeConfig);
});
it('should preserve min_date, max_date, time_interval, and datetime_config for datetime elements', () => {
const element: DialogElement = {
name: 'datetime_field',
type: DialogElementTypes.DATETIME,
display_name: 'Datetime Field',
optional: true,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
min_date: 'today',
max_date: '+30d',
time_interval: 60,
datetime_config: datetimeConfig,
};
const result = convertDialogElementToAppField(element);
expect(result.min_date).toBe('today');
expect(result.max_date).toBe('+30d');
expect(result.time_interval).toBe(60);
expect(result.datetime_config).toEqual(datetimeConfig);
});
it('should leave datetime-only fields undefined when not set on date element', () => {
const element: DialogElement = {
name: 'date_field',
type: DialogElementTypes.DATE,
display_name: 'Date Field',
optional: false,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
};
const result = convertDialogElementToAppField(element);
expect(result.min_date).toBeUndefined();
expect(result.max_date).toBeUndefined();
expect(result.time_interval).toBeUndefined();
expect(result.datetime_config).toBeUndefined();
});
it('should NOT copy min_date, max_date, time_interval, or datetime_config for text elements even if present', () => {
const element: DialogElement = {
name: 'text_field',
type: DialogElementTypes.TEXT,
display_name: 'Text Field',
optional: false,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
min_date: '2026-01-01',
max_date: '2026-12-31',
time_interval: 15,
datetime_config: datetimeConfig,
} as DialogElement;
const result = convertDialogElementToAppField(element);
expect(result.min_date).toBeUndefined();
expect(result.max_date).toBeUndefined();
expect(result.time_interval).toBeUndefined();
expect(result.datetime_config).toBeUndefined();
});
it('should NOT copy datetime props for select elements', () => {
const element: DialogElement = {
name: 'select_field',
type: DialogElementTypes.SELECT,
display_name: 'Select Field',
optional: true,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [{value: 'a', text: 'A'}],
min_date: '2026-01-01',
max_date: '2026-12-31',
time_interval: 15,
datetime_config: datetimeConfig,
} as DialogElement;
const result = convertDialogElementToAppField(element);
expect(result.min_date).toBeUndefined();
expect(result.max_date).toBeUndefined();
expect(result.time_interval).toBeUndefined();
expect(result.datetime_config).toBeUndefined();
});
});
});
describe('convertDialogToAppForm', () => {
const mockConfig: InteractiveDialogConfig = {
app_id: 'test-app',
dialog: {
callback_id: 'test-callback',
title: 'Test Dialog',
introduction_text: 'Please fill out the form',
elements: [
{
name: 'text_field',
type: DialogElementTypes.TEXT,
display_name: 'Text Field',
help_text: 'Enter some text',
optional: false,
default: 'default text',
placeholder: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
},
{
name: 'select_field',
type: DialogElementTypes.SELECT,
display_name: 'Select Field',
help_text: 'Choose an option',
optional: true,
options: [
{value: 'opt1', text: 'Option 1'},
{value: 'opt2', text: 'Option 2'},
],
default: '',
placeholder: '',
min_length: 0,
max_length: 0,
data_source: '',
},
],
submit_label: 'Submit',
state: 'test-state',
notify_on_cancel: false,
},
url: 'https://test.com/dialog',
trigger_id: 'test-trigger-id',
};
it('should convert dialog config to app form correctly', () => {
const result = convertDialogToAppForm(mockConfig);
expect(result).toEqual({
title: 'Test Dialog',
header: 'Please fill out the form',
fields: [
{
name: 'text_field',
type: 'text',
is_required: true,
label: 'Text Field',
description: 'Enter some text',
position: 0,
value: 'default text',
min_length: 0,
max_length: 0,
},
{
name: 'select_field',
type: 'static_select',
is_required: false,
label: 'Select Field',
description: 'Choose an option',
position: 1,
options: [
{label: 'Option 1', value: 'opt1'},
{label: 'Option 2', value: 'opt2'},
],
},
],
submit_buttons: undefined,
submit_label: 'Submit',
source: undefined,
submit: {
path: '/dialog/submit',
expand: {},
},
});
});
it('should handle dialog without elements', () => {
const configWithoutElements = {
...mockConfig,
dialog: {
...mockConfig.dialog,
elements: [],
},
};
const result = convertDialogToAppForm(configWithoutElements);
expect(result.fields).toEqual([]);
});
it('should handle dialog with empty elements array', () => {
const configWithEmptyElements = {
...mockConfig,
dialog: {
...mockConfig.dialog,
elements: [],
},
};
const result = convertDialogToAppForm(configWithEmptyElements);
expect(result.fields).toEqual([]);
});
it('should handle dialog without introduction text', () => {
const configWithoutIntro = {
...mockConfig,
dialog: {
...mockConfig.dialog,
introduction_text: '',
},
};
const result = convertDialogToAppForm(configWithoutIntro);
expect(result.header).toBeUndefined();
});
it('should set correct position for each field', () => {
const configWithManyFields = {
...mockConfig,
dialog: {
...mockConfig.dialog,
elements: [
{
name: 'field1',
type: DialogElementTypes.TEXT,
display_name: 'Field 1',
optional: false,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
},
{
name: 'field2',
type: DialogElementTypes.TEXT,
display_name: 'Field 2',
optional: false,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
},
{
name: 'field3',
type: DialogElementTypes.TEXT,
display_name: 'Field 3',
optional: false,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
options: [],
},
],
},
};
const result = convertDialogToAppForm(configWithManyFields as InteractiveDialogConfig);
expect(result.fields![0].position).toBe(0);
expect(result.fields![1].position).toBe(1);
expect(result.fields![2].position).toBe(2);
});
it('should always have the same submit structure', () => {
const result = convertDialogToAppForm(mockConfig);
expect(result.submit).toEqual({
path: '/dialog/submit',
expand: {},
});
expect(result.submit_buttons).toBeUndefined();
expect(result.source).toBeUndefined();
});
it('should set source when fields have refresh enabled', () => {
const configWithRefreshField = {
...mockConfig,
dialog: {
...mockConfig.dialog,
elements: [
...mockConfig.dialog.elements,
{
name: 'refresh_field',
type: DialogElementTypes.SELECT,
display_name: 'Refresh Field',
help_text: 'This field triggers refresh',
optional: false,
refresh: true,
options: [
{value: 'option1', text: 'Option 1'},
],
default: '',
placeholder: '',
min_length: 0,
max_length: 0,
data_source: '',
},
],
},
};
const result = convertDialogToAppForm(configWithRefreshField as InteractiveDialogConfig);
expect(result.source).toEqual({
path: '/dialog/refresh',
expand: {},
});
});
it('should set source when source_url is provided', () => {
const configWithSourceUrl = {
...mockConfig,
dialog: {
...mockConfig.dialog,
source_url: 'https://example.com/refresh',
},
};
const result = convertDialogToAppForm(configWithSourceUrl);
expect(result.source).toEqual({
path: 'https://example.com/refresh',
expand: {},
});
});
it('should prioritize source_url over default refresh path', () => {
const configWithBoth = {
...mockConfig,
dialog: {
...mockConfig.dialog,
source_url: 'https://example.com/custom-refresh',
elements: [
{
name: 'refresh_field',
type: DialogElementTypes.SELECT,
display_name: 'Refresh Field',
refresh: true,
options: [],
optional: false,
default: '',
placeholder: '',
help_text: '',
min_length: 0,
max_length: 0,
data_source: '',
},
],
},
};
const result = convertDialogToAppForm(configWithBoth as InteractiveDialogConfig);
expect(result.source).toEqual({
path: 'https://example.com/custom-refresh',
expand: {},
});
});
});
});