mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-08 20:20:13 +00:00
* feat(apps-form): add collapsible field type and typings Introduce the `collapsible` field type across the Apps and interactive dialog layers: the AppFieldTypes constant, the AppField `collapsible_config` (fields/expanded/bordered), and the DialogElement `collapsible_config` (elements/collapsed/borderless), plus the InteractiveDialogElementType union. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(apps-form): flatten collapsible sections in dialog conversion and submission Convert collapsible dialog elements into AppField sections (inverting collapsed->expanded and borderless->bordered) and add recursive flatten helpers (flattenCollapsible/flattenDialogElements/flattenAppFields). The adapter now flattens elements before building the submission so leaf fields nested inside collapsed sections are reachable by name and are no longer silently dropped from the payload. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(apps-form): render collapsible sections in the apps form Add the CollapsibleSection disclosure control (animated chevron, mount-on- expand content, reduced-motion support, and screen-reader affordances) and wire it into the apps form: sections initialize/submit their nested field values, empty sections render nothing, and a failed submit auto-expands any section containing an invalid field. Also render the BOOL field as an inline label + Switch row. Includes unit coverage for the section component, the inline bool toggle, and the section rendering/auto-expand behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(apps-form): present the apps form as a modal Register APPS_FORM as a modal screen and set headerTintColor so the modal's header button is visible, letting interactive dialogs open as a proper modal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(apps-form): expand unit coverage for collapsible sections Adds high-value behavioral tests for PR #10189 (collapsible sections): - New apps_form_component.collapsible.test.tsx: recursive rendering (depth-3), validation auto-expansion incl. server-returned errors, forceExpandVersion transitions, refresh value-merge preservation, nested onChange/lookup discovery, nested submit_buttons, and exact submission payload integrity. - dialog_utils: extensive flattenAppFields/flattenDialogElements/ flattenCollapsible cases (empty, ordering, siblings, missing config, empty nested, container omission, arbitrary depth, no mutation). - dialog_conversion: malformed/minimal collapsible conf, sibling top-level collapsibles, nested select/bool + depth-3 submission. - interactive_dialog_adapter: prove flattenDialogElements is applied and nested/deep values reach the legacy payload end-to-end. - collapsible_section: repeated force-expand increments; re-sync to collapsed on initiallyExpanded flip. Test-only change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(apps-form): scroll interactive dialog elements into view The apps form now renders inside a modal with a sticky header and a keyboard-aware scroll view. The interactive_dialog page object's fixed-distance scrolls no longer reliably reveal the target: the pre-scroll in fillTextElement pushed the first input under the modal header, and the single scroll in submit left the button behind the keyboard on long/multistep dialogs. Both surfaced as identical "element not visible" timeouts on iOS and Android in CI. Replace the fixed scrolls with scroll-to-visible (whileElement) so the helper scrolls only until the target input / submit button is actually visible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(apps-form): dismiss keyboard before revealing dialog elements The scroll-to-visible change fixed mid-form fields, but the lowest elements (textarea field, submit button on multistep dialogs) still timed out: an open keyboard from the previous field occludes them and the keyboard-aware scroll view cannot scroll them above it. CI failure screenshots confirmed the keyboard covering the target in every remaining case. Add a dismissKeyboard() helper (tap the scroll view background) and call it before scroll-to-visible in fillTextElement and submit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(apps-form): move submit action to the modal header The apps form is now a modal with a keyboard-aware scroll view. With the default submit button at the bottom of the body, the on-screen keyboard covered it on phones, making the primary action unreachable (and timing out the interactive_dialog E2E tests on iOS). Move the default submit action into the modal header-right slot, matching the app's existing modal-form pattern (edit_profile "Save", custom_status "Done"). It now stays above the keyboard and is always reachable. Forms that define their own submit_buttons keep those inline in the body. The testID (interactive_dialog.submit.button) is preserved. Update the apps_form unit tests to assert against the header registration, and simplify the E2E submit() helper (no scroll/keyboard-dismiss needed now that the button is always visible). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address coderabbit issues Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(apps-form): use a checkmark icon for the modal submit action The design team requested the header submit control be a blue circular checkmark button instead of a text label. The submit label now rides on accessibilityLabel so assistive tech still announces it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(apps-form): verify collapsible section renders across the 5 core themes Renders the section under denim, sapphire, quartz, indigo and onyx and asserts colors are theme-derived (label color tracks centerChannelColor), covering the checklist's cross-theme consistency requirement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address coderabbit: guard double submit and force-expand nested sections Add a synchronous submittingRef guard so a rapid double-tap on the header submit button cannot fire submit() twice before the submitting state re-renders. Seed CollapsibleSection expanded state from forceExpandVersion so a nested errored section that mounts only after its collapsed ancestor expands reveals its invalid field instead of staying collapsed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Recurse into unnamed collapsible sections when force-expanding on error computeExpandBumps returned before recursing for an unnamed collapsible section, so a named nested section holding an invalid required field was never bumped and stayed hidden after a failed submit. Always recurse; only bump sections that have a name (expandVersions is keyed by name). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(apps-form): fix lines-around-comment lint in collapsible test Add the required blank line before the submit_buttons explanatory comment so the lint-typecheck CI check passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Keep unnamed sections and nested refresh fields when converting dialogs Only exclude a field from visibleFields/childFields when submit_buttons is actually set, so unnamed collapsible sections are not dropped when a form has no submit_buttons. Detect refresh fields in the flattened element tree so a refresh field nested inside a collapsible section still sets form.source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add collapsible section accessibility-hint and layout unit tests Cover the accessibility hint toggling between expand/collapse, depth-based header indentation, the bordered chrome branch, and clean unmount while expanded — gaps the existing collapsible suite did not assert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix collapsible section reveal, nested lookups, and collapse animation Address QA findings on the apps-form collapsible sections: - Force-expand now keys sections by a stable path identity instead of by name, so an unnamed collapsed section holding an invalid field opens on a failed submit just like a named one (previously it blocked submission but could never be revealed). The same path also gives unnamed sibling sections distinct React keys. - Flatten dialog elements before resolving a dynamic-select lookup in DialogRouter, so a dynamic select nested inside a collapsible finds its element and loads options instead of silently returning nothing. - Add a FadeOut exiting animation to section content so it fades on collapse in step with the container height instead of popping away. - Drop the unused isFirstField parameter threaded through renderField. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Flatten collapsible sections in the app command parser The slash-command parser iterated resolvedForm.fields directly, so a collapsible container would be treated as a parameter and its nested leaf fields skipped for parameter matching, required-field detection, option expansion, and default/readonly values. Flatten collapsibles to their leaf fields once where resolvedForm is resolved, so every downstream step sees a flat parameter list — the same UI-only-grouping treatment apps_form_component and the dialog adapter already apply before validation and submission. A new form object is built to avoid mutating the cached binding form, and flattening is idempotent for forms that contain no collapsibles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address review feedback on collapsible sections - cleanForm: recurse into collapsible sections instead of treating a section as a removable leaf field, so a section whose title has spaces (or repeats a label) no longer drops itself and every field inside it, and nested selects are cleaned too. Add cleanForm unit tests. (sbishel) - collapsible_section: gate the mount-time force-expand seed on hasError so a section no longer re-expands on every remount after its error is fixed, since forceExpandVersion stays non-zero for the form's life. (sbishel) - apps_form_field: revert the inline BOOL Switch back to the shared BoolSetting, restoring the field hint, Markdown descriptions, and single screen-reader announcement. (sbishel) - Remove the collapsible tests duplicated in apps_form_component.test.tsx (covered by apps_form_component.collapsible.test.tsx) and hoist the shared database/beforeEach/afterEach to module level in the collapsible test. (sbishel) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix lint errors from merge: unused import and prop line wrap Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Consolidate section indent onto the container and trim comments Move the depth indent to a single inset on the section container instead of duplicating it on the header and content, and tighten verbose comments across the collapsible feature per review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add Detox E2E coverage for collapsible dialog sections Add testIDs to the collapsible section header and content, and cover the demo plugin's /dialog collapsible sample with Detox tests: default expand/collapse state, revealing nested fields on expand, and filling fields across sections before submit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Scroll deeper collapsible section headers into view before toggling Expanding an outer section grows the apps-form, pushing nested section headers below the fold. toggleSection asserted visibility and tapped without scrolling, so MM-T5602/MM-T5603 timed out on level3 toggles in CI. Mirror fillTextElement: dismiss the keyboard and scroll the header into view first. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Poll for the collapsible dialog submission post before asserting MM-T5603 closed the dialog on-device and immediately read the last post, but the plugin's "Dialog Submitted:" response post can land a beat later, so the single fetch captured a stale post id and the assertion timed out on iOS. Poll apiGetLastPostInChannel until the submission post exists. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Make the collapsible submit test reliable on iOS The CI failure artifact showed MM-T5603 never produced a "Dialog Submitted:" post on iOS: filling the company field inside the freshly-expanded level-2 section races the keyboard/scroll, so the submit silently drops and the lenient ensureDialogClosed cancels the dialog. company is optional and the dialog submits without it (Android already passes without last_name/email, which are nominally required), so drop the flaky nested fill. Fill the always-visible level-1 field, expand a section, submit, and assert the entered value reaches the submission payload. Nested reveal stays covered by MM-T5602. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add a settle delay before filling the expanded section in MM-T5603 Revert the submit test to its reviewed form and add one wait after expanding level 2, before typing into company. On iOS, filling while the section is still animating drops the input and the submit silently no-ops. This replaces the earlier poll/restructure attempts with the minimal, idiomatic delay. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Move collapsible dialog tests into their own E2E file Break the three collapsible tests out of interactive_dialog_plugin.e2e.ts into interactive_dialog_collapsible.e2e.ts, with the suite setup copied so the original file's other tests are untouched. Running against a fresh channel instead of after ~30 prior tests keeps the submission post on screen, so MM-T5603's final hasPostMessage check can find it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Verify the collapsible submission via the server post, not the UI MM-T5603's final hasPostMessage check waited for the mobile client to render the just-created submission post, which times out when the websocket render lags past 10s (seen on both iOS and Android). The submit itself works and the server has the post, so assert on the message from apiGetLastPostInChannel instead of re-checking the rendered post on screen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fill all required fields in the collapsible submit test MM-T5603 only filled first_name, so last_name and email (both required) were empty and the dialog's validation blocked the submit, producing no post. In the old shared file this was masked by a stale "Dialog Submitted:" post from an earlier test; the dedicated file and server-side assertion exposed it. Fill last_name and the nested email so the submission actually goes through. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add error-expansion E2E test and tidy collapsible test IDs Add MM-T<collapsible>-4 covering a collapsed section auto-expanding when its required field errors, then submitting. Switch the placeholder Zephyr IDs to MM-T<collapsible>-N per QA (also avoids colliding with the real Channel Bookmarks test IDs), and trim the verbose flatten comment in the app command parser. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Route collapsible dialog spec to server B and use shared setup Register the collapsible E2E spec in server_mutation_specs.json (target B, where the demo plugin lives) and call requireMutationServer, satisfying the server-mutation lint rule and manifest check added on main. Replace the stale inline suite setup copied from the pre-refactor plugin file with the shared interactive_dialog_plugin_helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Mattermost Build <build@mattermost.com>
1534 lines
57 KiB
TypeScript
1534 lines
57 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {
|
|
collectFileIds,
|
|
convertAppFormValuesToDialogSubmission,
|
|
convertDialogElementToAppField,
|
|
convertDialogToAppForm,
|
|
flattenFileFieldValues,
|
|
mergeFileFieldValues,
|
|
} from './dialog_conversion';
|
|
import {DialogElementTypes, flattenDialogElements} 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([]);
|
|
});
|
|
|
|
it('should convert file element values to a comma-joined id string', () => {
|
|
const fileElement: DialogElement = {
|
|
name: 'attachment',
|
|
type: DialogElementTypes.FILE,
|
|
display_name: 'Attachment',
|
|
optional: true,
|
|
allow_multiple: true,
|
|
default: '',
|
|
placeholder: '',
|
|
help_text: '',
|
|
min_length: 0,
|
|
max_length: 0,
|
|
data_source: '',
|
|
options: [],
|
|
};
|
|
|
|
const values: AppFormValues = {
|
|
attachment: 'file-id-1,file-id-2',
|
|
};
|
|
|
|
const result = convertAppFormValuesToDialogSubmission(values, [fileElement]);
|
|
|
|
expect(result.submission).toEqual({attachment: 'file-id-1,file-id-2'});
|
|
expect(result.errors).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('convertDialogElementToAppField', () => {
|
|
it('should convert a file element and carry allow_multiple', () => {
|
|
const element: DialogElement = {
|
|
name: 'attachment',
|
|
type: DialogElementTypes.FILE,
|
|
display_name: 'Attachment',
|
|
help_text: 'Upload files',
|
|
placeholder: '',
|
|
default: '',
|
|
optional: true,
|
|
allow_multiple: true,
|
|
min_length: 0,
|
|
max_length: 0,
|
|
data_source: '',
|
|
options: [],
|
|
};
|
|
|
|
const result = convertDialogElementToAppField(element);
|
|
|
|
expect(result.type).toBe('file');
|
|
expect(result.allow_multiple).toBe(true);
|
|
expect(result.is_required).toBe(false);
|
|
expect(result.label).toBe('Attachment');
|
|
});
|
|
|
|
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 a refresh field is nested inside a collapsible section', () => {
|
|
const configWithNestedRefresh = {
|
|
...mockConfig,
|
|
dialog: {
|
|
...mockConfig.dialog,
|
|
elements: [
|
|
{
|
|
name: 'section',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: 'Section',
|
|
collapsible_config: {
|
|
elements: [
|
|
{
|
|
name: 'refresh_field',
|
|
type: DialogElementTypes.SELECT,
|
|
display_name: 'Refresh Field',
|
|
refresh: true,
|
|
options: [{value: 'option1', text: 'Option 1'}],
|
|
optional: false,
|
|
default: '',
|
|
placeholder: '',
|
|
help_text: '',
|
|
min_length: 0,
|
|
max_length: 0,
|
|
data_source: '',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const result = convertDialogToAppForm(configWithNestedRefresh 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: {},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('collapsible sections (interactive dialog → AppForm → submission)', () => {
|
|
it('converts a collapsible element into a collapsible AppField with recursively converted children', () => {
|
|
const element: DialogElement = {
|
|
name: 'contact_section',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: 'Contact Info',
|
|
collapsible_config: {
|
|
elements: [
|
|
{name: 'email', type: DialogElementTypes.TEXT, display_name: 'Email', optional: true} as DialogElement,
|
|
{name: 'priority', type: DialogElementTypes.SELECT, display_name: 'Priority', optional: false, options: [{text: 'High', value: 'high'}]} as DialogElement,
|
|
],
|
|
},
|
|
} as DialogElement;
|
|
|
|
const field = convertDialogElementToAppField(element);
|
|
|
|
expect(field.type).toBe('collapsible');
|
|
expect(field.name).toBe('contact_section');
|
|
expect(field.label).toBe('Contact Info');
|
|
expect(field.collapsible_config?.fields).toHaveLength(2);
|
|
expect(field.collapsible_config?.fields?.[0]).toMatchObject({name: 'email', type: 'text', is_required: false});
|
|
expect(field.collapsible_config?.fields?.[1]).toMatchObject({name: 'priority', type: 'static_select', is_required: true});
|
|
expect(field.collapsible_config?.fields?.[1].options).toEqual([{label: 'High', value: 'high'}]);
|
|
});
|
|
|
|
it('maps collapsed/borderless to expanded/bordered (inverted), defaulting to expanded + bordered', () => {
|
|
const defaults = convertDialogElementToAppField({
|
|
name: 's',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: 'S',
|
|
collapsible_config: {elements: [] as DialogElement[]},
|
|
} as DialogElement);
|
|
expect(defaults.collapsible_config?.expanded).toBe(true);
|
|
expect(defaults.collapsible_config?.bordered).toBe(true);
|
|
|
|
const explicit = convertDialogElementToAppField({
|
|
name: 's',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: 'S',
|
|
collapsible_config: {elements: [] as DialogElement[], collapsed: true, borderless: true},
|
|
} as DialogElement);
|
|
expect(explicit.collapsible_config?.expanded).toBe(false);
|
|
expect(explicit.collapsible_config?.bordered).toBe(false);
|
|
});
|
|
|
|
it('converts nested (depth-2) collapsible sections recursively', () => {
|
|
const field = convertDialogElementToAppField({
|
|
name: 'outer',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: 'Outer',
|
|
collapsible_config: {
|
|
elements: [{
|
|
name: 'inner',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: 'Inner',
|
|
collapsible_config: {
|
|
elements: [{name: 'deep', type: DialogElementTypes.TEXT, display_name: 'Deep', optional: true} as DialogElement],
|
|
},
|
|
} as DialogElement],
|
|
},
|
|
} as DialogElement);
|
|
|
|
const inner = field.collapsible_config?.fields?.[0];
|
|
expect(inner?.type).toBe('collapsible');
|
|
expect(inner?.collapsible_config?.fields?.[0]).toMatchObject({name: 'deep', type: 'text'});
|
|
});
|
|
|
|
it('places a top-level collapsible field at its element index via convertDialogToAppForm', () => {
|
|
const form = convertDialogToAppForm({
|
|
dialog: {
|
|
title: 'T',
|
|
elements: [
|
|
{name: 'name', type: DialogElementTypes.TEXT, display_name: 'Full Name', optional: false} as DialogElement,
|
|
{name: 'contact_section', type: DialogElementTypes.COLLAPSIBLE, display_name: 'Contact Info', collapsible_config: {elements: [{name: 'email', type: DialogElementTypes.TEXT, display_name: 'Email', optional: true} as DialogElement]}} as DialogElement,
|
|
],
|
|
},
|
|
} as InteractiveDialogConfig);
|
|
|
|
expect(form.fields?.[0]).toMatchObject({name: 'name', position: 0});
|
|
expect(form.fields?.[1]).toMatchObject({name: 'contact_section', type: 'collapsible', position: 1});
|
|
});
|
|
|
|
it('submits values from fields nested in a collapsible once the elements are flattened', () => {
|
|
const elements: DialogElement[] = [
|
|
{name: 'name', type: DialogElementTypes.TEXT, display_name: 'Full Name', optional: false} as DialogElement,
|
|
{
|
|
name: 'contact_section',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: 'Contact Info',
|
|
collapsible_config: {
|
|
elements: [
|
|
{name: 'email', type: DialogElementTypes.TEXT, display_name: 'Email', optional: true} as DialogElement,
|
|
{name: 'priority', type: DialogElementTypes.SELECT, display_name: 'Priority', optional: true, options: [{text: 'High', value: 'high'}]} as DialogElement,
|
|
{name: 'notify', type: DialogElementTypes.BOOL, display_name: 'Notify', optional: true} as DialogElement,
|
|
],
|
|
},
|
|
} as DialogElement,
|
|
];
|
|
|
|
// The form holds a flat value map keyed by field name (select as an AppSelectOption).
|
|
const values: AppFormValues = {
|
|
name: 'Jane Doe',
|
|
email: 'jane@example.com',
|
|
priority: {label: 'High', value: 'high'},
|
|
notify: true,
|
|
};
|
|
|
|
const {submission, errors} = convertAppFormValuesToDialogSubmission(values, flattenDialogElements(elements));
|
|
|
|
expect(errors).toEqual([]);
|
|
expect(submission).toEqual({
|
|
name: 'Jane Doe',
|
|
email: 'jane@example.com',
|
|
priority: 'high',
|
|
notify: true,
|
|
});
|
|
});
|
|
|
|
it('drops nested values with an error when the elements are NOT flattened (documents why the adapter flattens)', () => {
|
|
const elements: DialogElement[] = [
|
|
{
|
|
name: 'contact_section',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: 'Contact Info',
|
|
collapsible_config: {
|
|
elements: [{name: 'email', type: DialogElementTypes.TEXT, display_name: 'Email', optional: true} as DialogElement],
|
|
},
|
|
} as DialogElement,
|
|
];
|
|
|
|
const {submission, errors} = convertAppFormValuesToDialogSubmission({email: 'jane@example.com'}, elements);
|
|
|
|
expect(submission.email).toBeUndefined();
|
|
expect(errors).toEqual(['Field email not found in dialog elements']);
|
|
});
|
|
|
|
it('defaults a collapsible with no collapsible_config to an empty, expanded, bordered section', () => {
|
|
const field = convertDialogElementToAppField({
|
|
name: 'bare',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: 'Bare',
|
|
} as DialogElement);
|
|
|
|
expect(field.type).toBe('collapsible');
|
|
expect(field.collapsible_config).toEqual({
|
|
fields: [],
|
|
expanded: true,
|
|
bordered: true,
|
|
});
|
|
});
|
|
|
|
it('uses the element name as the label fallback surface (label mirrors display_name, even when empty)', () => {
|
|
const field = convertDialogElementToAppField({
|
|
name: 'sec',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: '',
|
|
collapsible_config: {elements: [] as DialogElement[]},
|
|
} as DialogElement);
|
|
|
|
// The converter copies display_name verbatim; the component owns the
|
|
// name-fallback for the visible label.
|
|
expect(field.label).toBe('');
|
|
expect(field.name).toBe('sec');
|
|
});
|
|
|
|
it('preserves sibling top-level collapsibles with correct positions via convertDialogToAppForm', () => {
|
|
const form = convertDialogToAppForm({
|
|
dialog: {
|
|
title: 'T',
|
|
elements: [
|
|
{name: 'sec_a', type: DialogElementTypes.COLLAPSIBLE, display_name: 'A', collapsible_config: {elements: [{name: 'a1', type: DialogElementTypes.TEXT, display_name: 'A1', optional: true} as DialogElement]}} as DialogElement,
|
|
{name: 'sec_b', type: DialogElementTypes.COLLAPSIBLE, display_name: 'B', collapsible_config: {elements: [{name: 'b1', type: DialogElementTypes.TEXT, display_name: 'B1', optional: true} as DialogElement]}} as DialogElement,
|
|
],
|
|
},
|
|
} as InteractiveDialogConfig);
|
|
|
|
expect(form.fields).toHaveLength(2);
|
|
expect(form.fields?.[0]).toMatchObject({name: 'sec_a', type: 'collapsible', position: 0});
|
|
expect(form.fields?.[1]).toMatchObject({name: 'sec_b', type: 'collapsible', position: 1});
|
|
|
|
// Nested children keep their own converted shape; positions are only set at the top level.
|
|
expect(form.fields?.[0].collapsible_config?.fields?.[0]).toMatchObject({name: 'a1', type: 'text'});
|
|
});
|
|
|
|
it('converts nested select and bool leaves inside a collapsible (round-trips through submission)', () => {
|
|
const elements: DialogElement[] = [
|
|
{
|
|
name: 'prefs',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: 'Preferences',
|
|
collapsible_config: {
|
|
elements: [
|
|
{name: 'severity', type: DialogElementTypes.SELECT, display_name: 'Severity', optional: false, options: [{text: 'Low', value: 'low'}, {text: 'High', value: 'high'}]} as DialogElement,
|
|
{name: 'silent', type: DialogElementTypes.BOOL, display_name: 'Silent', optional: true} as DialogElement,
|
|
],
|
|
},
|
|
} as DialogElement,
|
|
];
|
|
|
|
const {submission, errors} = convertAppFormValuesToDialogSubmission(
|
|
{severity: {label: 'High', value: 'high'}, silent: false},
|
|
flattenDialogElements(elements),
|
|
);
|
|
|
|
expect(errors).toEqual([]);
|
|
expect(submission).toEqual({severity: 'high', silent: false});
|
|
});
|
|
|
|
it('carries deeply nested (depth-3) leaf values into submission once flattened', () => {
|
|
const elements: DialogElement[] = [
|
|
{
|
|
name: 'l1',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: 'L1',
|
|
collapsible_config: {
|
|
elements: [
|
|
{
|
|
name: 'l2',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: 'L2',
|
|
collapsible_config: {
|
|
elements: [
|
|
{
|
|
name: 'l3',
|
|
type: DialogElementTypes.COLLAPSIBLE,
|
|
display_name: 'L3',
|
|
collapsible_config: {elements: [{name: 'deep', type: DialogElementTypes.TEXT, display_name: 'Deep', optional: true} as DialogElement]},
|
|
} as DialogElement,
|
|
],
|
|
},
|
|
} as DialogElement,
|
|
],
|
|
},
|
|
} as DialogElement,
|
|
];
|
|
|
|
const {submission, errors} = convertAppFormValuesToDialogSubmission({deep: 'value'}, flattenDialogElements(elements));
|
|
|
|
expect(errors).toEqual([]);
|
|
expect(submission).toEqual({deep: 'value'});
|
|
});
|
|
});
|
|
|
|
describe('collectFileIds', () => {
|
|
const fileEl = (name: string): DialogElement => ({
|
|
name,
|
|
type: DialogElementTypes.FILE,
|
|
display_name: '',
|
|
subtype: undefined,
|
|
default: '',
|
|
placeholder: '',
|
|
help_text: '',
|
|
optional: true,
|
|
min_length: 0,
|
|
max_length: 0,
|
|
data_source: '',
|
|
options: [],
|
|
});
|
|
|
|
it('collects and trims comma-joined IDs from FILE elements', () => {
|
|
expect(collectFileIds({f1: ' a , b ,c'}, [fileEl('f1')])).toEqual(['a', 'b', 'c']);
|
|
});
|
|
|
|
it('collects across multiple FILE fields', () => {
|
|
expect(collectFileIds({f1: 'a,b', f2: 'c'}, [fileEl('f1'), fileEl('f2')])).toEqual(['a', 'b', 'c']);
|
|
});
|
|
|
|
it('deduplicates repeated IDs', () => {
|
|
expect(collectFileIds({f1: 'a,a', f2: 'a,b'}, [fileEl('f1'), fileEl('f2')])).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('ignores non-FILE elements and empty values', () => {
|
|
const textEl: DialogElement = {...fileEl('t1'), type: DialogElementTypes.TEXT};
|
|
expect(collectFileIds({t1: 'x,y', f1: ''}, [textEl, fileEl('f1')])).toEqual([]);
|
|
});
|
|
|
|
it('returns an empty array when elements is omitted', () => {
|
|
expect(collectFileIds({f1: 'a'})).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('mergeFileFieldValues', () => {
|
|
const fileEl = (name: string): DialogElement => ({
|
|
name,
|
|
type: DialogElementTypes.FILE,
|
|
display_name: '',
|
|
subtype: undefined,
|
|
default: '',
|
|
placeholder: '',
|
|
help_text: '',
|
|
optional: true,
|
|
min_length: 0,
|
|
max_length: 0,
|
|
data_source: '',
|
|
options: [],
|
|
});
|
|
|
|
it('replaces a field value instead of appending', () => {
|
|
const merged = mergeFileFieldValues(
|
|
{step1_file: 'old-id'},
|
|
{step1_file: 'new-id'},
|
|
[fileEl('step1_file')],
|
|
);
|
|
expect(merged).toEqual({step1_file: 'new-id'});
|
|
});
|
|
|
|
it('clears a field when the step submits an empty value', () => {
|
|
const merged = mergeFileFieldValues(
|
|
{step1_file: 'old-id'},
|
|
{step1_file: ''},
|
|
[fileEl('step1_file')],
|
|
);
|
|
expect(merged).toEqual({step1_file: ''});
|
|
});
|
|
|
|
it('preserves unrelated accumulated fields', () => {
|
|
const merged = mergeFileFieldValues(
|
|
{step1_file: 'a', step2_file: 'b'},
|
|
{step2_file: 'c'},
|
|
[fileEl('step2_file')],
|
|
);
|
|
expect(merged).toEqual({step1_file: 'a', step2_file: 'c'});
|
|
});
|
|
|
|
it('ignores FILE elements whose names match inherited object keys', () => {
|
|
const submission = Object.create({toString: 'inherited-id'}) as Record<string, string>;
|
|
submission.attachment = 'real-id';
|
|
|
|
const merged = mergeFileFieldValues(
|
|
{},
|
|
submission,
|
|
[fileEl('toString'), fileEl('attachment')],
|
|
);
|
|
|
|
expect(merged).toEqual({attachment: 'real-id'});
|
|
});
|
|
});
|
|
|
|
describe('flattenFileFieldValues', () => {
|
|
it('flattens and deduplicates per-field comma-joined IDs', () => {
|
|
expect(flattenFileFieldValues({
|
|
f1: 'a,b',
|
|
f2: 'b,c',
|
|
})).toEqual(['a', 'b', 'c']);
|
|
});
|
|
});
|
|
});
|