mattermost-mobile-upstream/app/utils/apps.test.ts
Kyle Doliente b8aa29566a
Add collapsible sections to mobile (#10189)
* 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>
2026-10-23 09:11:52 -07:00

1062 lines
36 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {AppBindingLocations, AppCallResponseTypes, AppFieldTypes} from '@constants/apps';
import {
cleanBinding,
validateBindings,
cleanForm,
makeCallErrorResponse,
filterEmptyOptions,
createCallContext,
createCallRequest,
isAppBinding,
} from './apps';
describe('cleanBinding', () => {
it('should return the binding unchanged if it is null', () => {
expect(cleanBinding(null as any, AppBindingLocations.COMMAND)).toBeNull();
});
it('should handle default values for app_id, label, and location', () => {
const binding: AppBinding = {
app_id: 'test_app',
location: 'location',
label: 'label',
bindings: [
{
app_id: '',
location: '',
label: '',
form: {
submit: {
path: 'submit_path',
},
},
},
],
};
const result = cleanBinding(binding, AppBindingLocations.COMMAND);
expect(result!.bindings![0].app_id).toBe('test_app');
expect(result!.bindings![0].label).toBeTruthy();
expect(result!.bindings![0].location).toMatch(/location\/.+/);
});
it('should remove bindings without app_id', () => {
const binding: AppBinding = {
app_id: 'test_app',
location: 'location',
label: 'label',
bindings: [
{
app_id: '',
location: 'sub_location1',
label: 'sub_label1',
},
{
app_id: 'sub_app',
location: 'sub_location2',
label: 'sub_label2',
form: {
submit: {
path: 'submit_path',
},
},
},
],
};
const result = cleanBinding(binding, AppBindingLocations.COMMAND);
expect(result!.bindings).toHaveLength(1);
expect(result!.bindings![0].app_id).toBe('sub_app');
});
it('should remove bindings with empty or whitespace labels', () => {
const binding: AppBinding = {
app_id: 'test_app',
location: 'location',
label: 'label',
bindings: [
{
app_id: 'sub_app1',
location: 'sub_location1',
label: '',
},
{
app_id: 'sub_app2',
location: 'sub_location2',
label: ' ',
form: {
submit: {
path: 'submit_path',
},
},
},
{
app_id: 'sub_app3',
location: 'sub_location3',
label: 'valid_label',
form: {
submit: {
path: 'submit_path',
},
},
},
],
};
const result = cleanBinding(binding, AppBindingLocations.COMMAND);
expect(result!.bindings).toHaveLength(1);
expect(result!.bindings![0].label).toBe('valid_label');
});
it('should remove bindings with duplicate labels in COMMAND location', () => {
const binding: AppBinding = {
app_id: 'test_app',
location: 'location',
label: 'label',
bindings: [
{
app_id: 'sub_app1',
location: 'sub_location1',
label: 'sub_label',
form: {
submit: {
path: 'submit_path',
},
},
},
{
app_id: 'sub_app2',
location: 'sub_location2',
label: 'sub_label',
form: {
submit: {
path: 'submit_path',
},
},
},
],
};
const result = cleanBinding(binding, AppBindingLocations.COMMAND);
expect(result!.bindings).toHaveLength(1);
expect(result!.bindings![0].app_id).toBe('sub_app1');
});
it('should keep unique labels in IN_POST location', () => {
const binding: AppBinding = {
app_id: 'test_app',
location: 'location',
label: 'label',
bindings: [
{
app_id: 'sub_app1',
location: 'sub_location1',
label: 'sub_label1',
form: {
submit: {
path: 'submit_path',
},
},
},
{
app_id: 'sub_app2',
location: 'sub_location2',
label: 'sub_label2',
form: {
submit: {
path: 'submit_path',
},
},
},
],
};
const result = cleanBinding(binding, AppBindingLocations.IN_POST);
expect(result!.bindings).toHaveLength(2);
});
it('should remove invalid sub-bindings without form or submit', () => {
const binding: AppBinding = {
app_id: 'test_app',
location: 'location',
label: 'label',
bindings: [
{
app_id: 'sub_app1',
location: 'sub_location1',
label: 'sub_label1',
},
{
app_id: 'sub_app2',
location: 'sub_location2',
label: 'sub_label2',
form: {
submit: {
path: 'submit_path',
},
},
},
{
app_id: 'sub_app3',
location: 'sub_location3',
label: 'sub_label3',
submit: {
path: 'submit_path',
},
},
],
};
const result = cleanBinding(binding, AppBindingLocations.COMMAND);
expect(result!.bindings).toHaveLength(2);
expect(result!.bindings![0].label).toBe('sub_label2');
expect(result!.bindings![1].label).toBe('sub_label3');
});
it('should handle forms correctly', () => {
const binding: AppBinding = {
app_id: 'test_app',
location: 'location',
label: 'label',
bindings: [
{
app_id: 'sub_app',
location: 'sub_location',
label: 'sub_label',
form: {
submit: {
path: 'submit_path',
},
},
},
],
};
const result = cleanBinding(binding, AppBindingLocations.COMMAND);
expect(result!.bindings).toHaveLength(1);
expect(result!.bindings![0].form).toBeTruthy();
});
it('should handle submit calls correctly', () => {
const binding: AppBinding = {
app_id: 'test_app',
location: 'location',
label: 'label',
bindings: [
{
app_id: 'sub_app',
location: 'sub_location',
label: 'sub_label',
submit: {
path: 'submit_path',
},
},
],
};
const result = cleanBinding(binding, AppBindingLocations.COMMAND);
expect(result!.bindings).toHaveLength(1);
expect(result!.bindings![0].submit).toBeTruthy();
});
it('should recursively clean sub-bindings', () => {
const binding: AppBinding = {
app_id: 'test_app',
location: 'location',
label: 'label',
bindings: [
{
app_id: 'sub_app',
location: 'sub_location',
label: 'sub_label',
bindings: [
{
app_id: '',
location: 'sub_sub_location1',
label: '',
},
{
app_id: 'sub_sub_app',
location: 'sub_sub_location2',
label: 'sub_sub_label',
form: {
submit: {
path: 'submit_path',
},
},
},
],
},
],
};
const result = cleanBinding(binding, AppBindingLocations.COMMAND);
expect(result!.bindings).toHaveLength(1);
expect(result!.bindings![0].bindings).toHaveLength(1);
expect(result!.bindings![0].bindings![0].label).toBe('sub_sub_label');
});
it('should handle bindings without bindings, form, or submit', () => {
const binding: AppBinding = {
app_id: 'test_app',
location: 'location',
label: 'label',
bindings: [
{
app_id: 'sub_app',
location: 'sub_location',
label: 'sub_label',
},
],
};
const result = cleanBinding(binding, AppBindingLocations.COMMAND);
expect(result!.bindings).toHaveLength(0);
});
it('should handle multiple levels of nested bindings correctly', () => {
const binding: AppBinding = {
app_id: 'test_app',
location: 'location',
label: 'label',
bindings: [
{
app_id: 'sub_app1',
location: 'sub_location1',
label: 'sub_label1',
bindings: [
{
app_id: '',
location: 'sub_sub_location1',
label: '',
},
{
app_id: 'sub_sub_app',
location: 'sub_sub_location2',
label: 'sub_sub_label',
form: {
submit: {
path: 'submit_path',
},
},
},
],
},
{
app_id: 'sub_app2',
location: 'sub_location2',
label: 'sub_label2',
form: {
submit: {
path: 'submit_path',
},
},
},
],
};
const result = cleanBinding(binding, AppBindingLocations.COMMAND);
expect(result!.bindings).toHaveLength(2);
expect(result!.bindings![0].bindings).toHaveLength(1);
expect(result!.bindings![0].bindings![0].label).toBe('sub_sub_label');
expect(result!.bindings![1].label).toBe('sub_label2');
});
});
describe('validateBindings', () => {
it('should return an empty array if no bindings are provided', () => {
expect(validateBindings()).toEqual([]);
});
it('should filter and clean bindings by their locations', () => {
const bindings: AppBinding[] = [
{app_id: '1', location: AppBindingLocations.CHANNEL_HEADER_ICON, label: 'channel_header', bindings: []},
{app_id: '2', location: AppBindingLocations.POST_MENU_ITEM, label: 'post_menu', bindings: []},
{app_id: '3', location: AppBindingLocations.COMMAND, label: 'command', bindings: []},
{app_id: '4', location: 'other', label: 'other', bindings: []},
];
const result = validateBindings(bindings);
expect(result).toEqual([]);
});
it('should return only bindings that have sub-bindings after cleaning', () => {
const bindings: AppBinding[] = [
{app_id: '1', location: AppBindingLocations.CHANNEL_HEADER_ICON, label: 'channel_header', bindings: []},
{app_id: '2',
location: AppBindingLocations.POST_MENU_ITEM,
label: 'post_menu',
bindings: [{
app_id: '2.1',
label: 'sub_binding',
location: '',
form: {
submit: {
path: 'path_2',
},
},
}]},
{app_id: '3',
location: AppBindingLocations.COMMAND,
label: 'command',
bindings: [{
app_id: '3.1',
label: 'sub_binding',
location: '',
form: {
submit: {
path: 'path_3',
},
},
}]},
];
const result = validateBindings(bindings);
expect(result).toHaveLength(2);
expect(result[0].app_id).toBe('2');
expect(result[1].app_id).toBe('3');
});
it('should filter out bindings that do not have sub-bindings after cleaning', () => {
const bindings: AppBinding[] = [
{app_id: '1',
location: AppBindingLocations.CHANNEL_HEADER_ICON,
label: 'channel_header',
bindings: [{
app_id: '1.1',
label: 'sub_binding',
location: '',
}]},
{app_id: '2', location: AppBindingLocations.POST_MENU_ITEM, label: 'post_menu', bindings: []},
{app_id: '3', location: AppBindingLocations.COMMAND, label: 'command', bindings: []},
];
const result = validateBindings(bindings);
expect(result).toEqual([]);
});
it('should handle bindings with various sub-bindings and forms correctly', () => {
const bindings: AppBinding[] = [
{
app_id: '1',
location: AppBindingLocations.CHANNEL_HEADER_ICON,
label: 'channel_header',
bindings: [{
app_id: '1.1',
label: 'sub_binding1',
location: '',
form: {
submit: {
path: 'path_1',
},
},
}]},
{
app_id: '2',
location: AppBindingLocations.POST_MENU_ITEM,
label: 'post_menu',
bindings: [{
app_id: '2.1',
label: 'sub_binding2',
location: '',
form: {
submit: {
path: 'path_2',
},
},
}]},
{app_id: '3', location: AppBindingLocations.COMMAND, label: 'command', bindings: []},
];
const result = validateBindings(bindings);
expect(result).toHaveLength(2);
expect(result[0].app_id).toBe('2');
expect(result[1].app_id).toBe('1');
});
});
describe('cleanForm', () => {
it('should return immediately if form is undefined', () => {
expect(() => cleanForm(undefined)).not.toThrow();
});
it('should remove fields without names', () => {
const form: AppForm = {
fields: [
{name: '', type: 'text'} as AppField,
{name: 'valid_name', type: 'text'} as AppField,
],
};
cleanForm(form);
expect(form.fields).toHaveLength(1);
expect(form.fields![0].name).toBe('valid_name');
});
it('should remove fields with names containing spaces or tabs', () => {
const form: AppForm = {
fields: [
{name: 'invalid name', type: 'text'} as AppField,
{name: 'invalid\tname', type: 'text'} as AppField,
{name: 'valid_name', type: 'text'} as AppField,
],
};
cleanForm(form);
expect(form.fields).toHaveLength(1);
expect(form.fields![0].name).toBe('valid_name');
});
it('should remove fields with duplicate or invalid labels', () => {
const form: AppForm = {
fields: [
{name: 'name1', type: 'text', label: 'label1'} as AppField,
{name: 'name2', type: 'text', label: 'label1'} as AppField, // Duplicate label
{name: 'name3', type: 'text', label: 'invalid label'} as AppField,
{name: 'name4', type: 'text'} as AppField, // No label, should use name
{name: 'name5', type: 'text', label: 'label5'} as AppField,
],
};
cleanForm(form);
expect(form.fields).toHaveLength(3);
expect(form.fields![0].name).toBe('name1');
expect(form.fields![1].name).toBe('name4');
expect(form.fields![2].name).toBe('name5');
});
it('should handle STATIC_SELECT fields and remove invalid options', () => {
const form: AppForm = {
fields: [
{
name: 'select_field',
type: AppFieldTypes.STATIC_SELECT,
options: [
{label: 'option1', value: 'value1'},
{label: '', value: 'value2'},
{label: 'option1', value: 'value3'},
{label: 'option4', value: ''},
],
} as AppField,
],
};
cleanForm(form);
expect(form.fields).toHaveLength(1);
expect(form.fields![0].options).toHaveLength(3);
});
it('should retain valid fields and options', () => {
const form: AppForm = {
fields: [
{name: 'valid_name', type: 'text'} as AppField,
{
name: 'select_field',
type: AppFieldTypes.STATIC_SELECT,
options: [
{label: 'option1', value: 'value1'},
{label: 'option2', value: 'value2'},
],
} as AppField,
{name: 'dynamic_field_with_lookup', type: AppFieldTypes.DYNAMIC_SELECT, lookup: {path: 'lookup_path'}} as AppField,
],
};
cleanForm(form);
expect(form.fields).toHaveLength(3);
});
it('should keep a collapsible section whose title has spaces and preserve its fields', () => {
const form: AppForm = {
fields: [
{
name: 'advanced',
type: AppFieldTypes.COLLAPSIBLE,
label: 'Advanced options',
collapsible_config: {
fields: [
{name: 'required_field', type: 'text', label: 'Required'} as AppField,
],
},
} as AppField,
],
};
cleanForm(form);
expect(form.fields).toHaveLength(1);
expect(form.fields![0].name).toBe('advanced');
expect(form.fields![0].collapsible_config?.fields).toHaveLength(1);
expect(form.fields![0].collapsible_config?.fields![0].name).toBe('required_field');
});
it('should recurse into a collapsible section and clean its nested fields', () => {
const form: AppForm = {
fields: [
{
name: 'section',
type: AppFieldTypes.COLLAPSIBLE,
label: 'Section',
collapsible_config: {
fields: [
{name: '', type: 'text'} as AppField, // removed: no name
{name: 'bad name', type: 'text'} as AppField, // removed: space in name
{name: 'empty_select', type: AppFieldTypes.STATIC_SELECT, options: []} as AppField, // removed: no options
{name: 'dynamic_no_lookup', type: AppFieldTypes.DYNAMIC_SELECT} as AppField, // removed: no lookup
{name: 'keep', type: 'text', label: 'Keep'} as AppField,
],
},
} as AppField,
],
};
cleanForm(form);
const nested = form.fields![0].collapsible_config?.fields;
expect(nested).toHaveLength(1);
expect(nested![0].name).toBe('keep');
});
it('should enforce label uniqueness across the flattened section tree', () => {
const form: AppForm = {
fields: [
{name: 'top', type: 'text', label: 'shared'} as AppField,
{
name: 'section',
type: AppFieldTypes.COLLAPSIBLE,
label: 'Section',
collapsible_config: {
fields: [
{name: 'nested_dup', type: 'text', label: 'shared'} as AppField, // removed: duplicates top-level label
{name: 'nested_ok', type: 'text', label: 'unique'} as AppField,
],
},
} as AppField,
],
};
cleanForm(form);
expect(form.fields![0].name).toBe('top');
const nested = form.fields![1].collapsible_config?.fields;
expect(nested).toHaveLength(1);
expect(nested![0].name).toBe('nested_ok');
});
});
describe('makeCallErrorResponse', () => {
it('should create an error response object', () => {
const errorMessage = 'An error occurred';
const errorResponse = makeCallErrorResponse(errorMessage);
expect(errorResponse.type).toBe(AppCallResponseTypes.ERROR);
expect(errorResponse.text).toBe(errorMessage);
});
});
describe('filterEmptyOptions', () => {
it('should filter out empty options', () => {
const options: AppSelectOption[] = [
{label: 'Option 1', value: 'value1'},
{label: 'Option 2', value: ' '},
{label: 'Option 3', value: ''},
{label: 'Option 4', value: 'value2'},
];
const filteredOptions = options.filter(filterEmptyOptions);
// Check that empty options have been filtered out
expect(filteredOptions.length).toBe(2);
expect(filteredOptions.map((option) => option.label)).toEqual(['Option 1', 'Option 4']);
});
});
describe('createCallContext', () => {
it('should create context with all parameters provided', () => {
const context = createCallContext('appID123', 'location123', 'channelID123', 'teamID123', 'postID123', 'rootID123');
expect(context).toEqual({
app_id: 'appID123',
location: 'location123',
channel_id: 'channelID123',
team_id: 'teamID123',
post_id: 'postID123',
root_id: 'rootID123',
});
});
it('should create context with only appID provided', () => {
const context = createCallContext('appID123');
expect(context).toEqual({
app_id: 'appID123',
location: undefined,
channel_id: undefined,
team_id: undefined,
post_id: undefined,
root_id: undefined,
});
});
it('should create context with some parameters provided', () => {
const context = createCallContext('appID123', 'location123', 'channelID123');
expect(context).toEqual({
app_id: 'appID123',
location: 'location123',
channel_id: 'channelID123',
team_id: undefined,
post_id: undefined,
root_id: undefined,
});
});
});
describe('createCallRequest', () => {
const mockAppCall: AppCall = {
path: '/mock/path',
expand: {
app: 'all',
},
};
const mockAppContext: AppContext = {
app_id: 'appID123',
location: 'location123',
channel_id: 'channelID123',
team_id: 'teamID123',
post_id: 'postID123',
root_id: 'rootID123',
};
const mockDefaultExpand: AppExpand = {
user: 'all',
post: 'none',
};
const mockValues: AppCallValues = {
key1: 'value1',
key2: 'value2',
};
it('should create call request with all parameters provided', () => {
const rawCommand = '/mock command';
const request = createCallRequest(mockAppCall, mockAppContext, mockDefaultExpand, mockValues, rawCommand);
expect(request).toEqual({
...mockAppCall,
context: mockAppContext,
values: mockValues,
expand: {
...mockDefaultExpand,
...mockAppCall.expand,
},
raw_command: rawCommand,
});
});
it('should create call request with required parameters only', () => {
const request = createCallRequest(mockAppCall, mockAppContext);
expect(request).toEqual({
...mockAppCall,
context: mockAppContext,
expand: {
...mockAppCall.expand,
},
values: undefined,
raw_command: undefined,
});
});
it('should create call request with default expand only', () => {
const request = createCallRequest(mockAppCall, mockAppContext, mockDefaultExpand);
expect(request).toEqual({
...mockAppCall,
context: mockAppContext,
expand: {
...mockDefaultExpand,
...mockAppCall.expand,
},
values: undefined,
raw_command: undefined,
});
});
it('should create call request with values only', () => {
const request = createCallRequest(mockAppCall, mockAppContext, {}, mockValues);
expect(request).toEqual({
...mockAppCall,
context: mockAppContext,
expand: {
...mockAppCall.expand,
},
values: mockValues,
raw_command: undefined,
});
});
it('should create call request with raw command only', () => {
const rawCommand = '/mock command';
const request = createCallRequest(mockAppCall, mockAppContext, {}, undefined, rawCommand);
expect(request).toEqual({
...mockAppCall,
context: mockAppContext,
expand: {
...mockAppCall.expand,
},
values: undefined,
raw_command: rawCommand,
});
});
it('should create call request with overridden expand values', () => {
const callWithExpandOverride: AppCall = {
...mockAppCall,
expand: {
app: 'none',
user: 'summary',
},
};
const request = createCallRequest(callWithExpandOverride, mockAppContext, mockDefaultExpand);
expect(request).toEqual({
...callWithExpandOverride,
context: mockAppContext,
expand: {
...mockDefaultExpand,
...callWithExpandOverride.expand,
},
values: undefined,
raw_command: undefined,
});
});
});
describe('isAppBinding', () => {
it('should return false for null', () => {
expect(isAppBinding(null)).toBe(false);
});
it('should return false for non-object', () => {
expect(isAppBinding('string')).toBe(false);
expect(isAppBinding(42)).toBe(false);
});
it('should return true for empty object', () => {
expect(isAppBinding({})).toBe(true);
});
it('should return true for valid binding with string fields', () => {
expect(isAppBinding({
app_id: 'app1',
label: 'My Binding',
location: '/command',
icon: 'icon.png',
hint: 'hint text',
description: 'desc',
role_id: 'role1',
})).toBe(true);
});
it('should return false when app_id is not a string', () => {
expect(isAppBinding({app_id: 123})).toBe(false);
});
it('should return false when label is not a string', () => {
expect(isAppBinding({label: true})).toBe(false);
});
it('should return false when location is not a string', () => {
expect(isAppBinding({location: {}})).toBe(false);
});
it('should return false when depends_on_team is not a boolean', () => {
expect(isAppBinding({depends_on_team: 'yes'})).toBe(false);
});
it('should return false when depends_on_channel is not a boolean', () => {
expect(isAppBinding({depends_on_channel: 1})).toBe(false);
});
it('should return false when depends_on_user is not a boolean', () => {
expect(isAppBinding({depends_on_user: 'true'})).toBe(false);
});
it('should return false when depends_on_post is not a boolean', () => {
expect(isAppBinding({depends_on_post: 0})).toBe(false);
});
it('should return true for binding with valid nested bindings', () => {
expect(isAppBinding({
bindings: [{app_id: 'app1', label: 'sub', submit: {path: '/do'}}],
})).toBe(true);
});
it('should return false when bindings contains invalid entry', () => {
expect(isAppBinding({bindings: [{app_id: 123}]})).toBe(false);
});
it('should return true for binding with valid submit call', () => {
expect(isAppBinding({submit: {path: '/submit'}})).toBe(true);
});
it('should return false when submit has invalid expand', () => {
expect(isAppBinding({submit: {expand: {app: 123}}})).toBe(false);
});
it('should return false when submit.path is not a string', () => {
expect(isAppBinding({submit: {path: 42}})).toBe(false);
});
it('should return true for binding with valid form', () => {
expect(isAppBinding({
form: {
title: 'My Form',
submit: {path: '/submit'},
fields: [
{name: 'field1', type: 'text', is_required: true, readonly: false},
],
},
})).toBe(true);
});
it('should return false when form has invalid title type', () => {
expect(isAppBinding({form: {title: 123}})).toBe(false);
});
it('should return false when form has invalid header type', () => {
expect(isAppBinding({form: {header: false}})).toBe(false);
});
it('should return false when form has invalid footer type', () => {
expect(isAppBinding({form: {footer: []}})).toBe(false);
});
it('should return false when form has invalid icon type', () => {
expect(isAppBinding({form: {icon: 0}})).toBe(false);
});
it('should return false when form has invalid submit_buttons type', () => {
expect(isAppBinding({form: {submit_buttons: true}})).toBe(false);
});
it('should return false when form has invalid cancel_button type', () => {
expect(isAppBinding({form: {cancel_button: 'yes'}})).toBe(false);
});
it('should return false when form has invalid submit_on_cancel type', () => {
expect(isAppBinding({form: {submit_on_cancel: 1}})).toBe(false);
});
it('should return false when form.fields contains invalid field', () => {
expect(isAppBinding({form: {fields: [{name: 123}]}})).toBe(false);
});
it('should return false when form.source is invalid call', () => {
expect(isAppBinding({form: {source: {path: 99}}})).toBe(false);
});
it('should return false when form.submit is invalid call', () => {
expect(isAppBinding({form: {submit: {path: false}}})).toBe(false);
});
it('should return false when form.depends_on is not string array', () => {
expect(isAppBinding({form: {depends_on: [1, 2]}})).toBe(false);
});
it('should return false when field.is_required is not boolean', () => {
expect(isAppBinding({form: {fields: [{name: 'f', is_required: 'yes'}]}})).toBe(false);
});
it('should return false when field.readonly is not boolean', () => {
expect(isAppBinding({form: {fields: [{name: 'f', readonly: 1}]}})).toBe(false);
});
it('should return false when field.position is not number', () => {
expect(isAppBinding({form: {fields: [{name: 'f', position: 'first'}]}})).toBe(false);
});
it('should return false when field.modal_label is not string', () => {
expect(isAppBinding({form: {fields: [{name: 'f', modal_label: true}]}})).toBe(false);
});
it('should return false when field.refresh is not boolean', () => {
expect(isAppBinding({form: {fields: [{name: 'f', refresh: 'true'}]}})).toBe(false);
});
it('should return false when field.multiselect is not boolean', () => {
expect(isAppBinding({form: {fields: [{name: 'f', multiselect: 1}]}})).toBe(false);
});
it('should return false when field.subtype is not string', () => {
expect(isAppBinding({form: {fields: [{name: 'f', subtype: 5}]}})).toBe(false);
});
it('should return false when field.min_length is not number', () => {
expect(isAppBinding({form: {fields: [{name: 'f', min_length: '5'}]}})).toBe(false);
});
it('should return false when field.max_length is not number', () => {
expect(isAppBinding({form: {fields: [{name: 'f', max_length: true}]}})).toBe(false);
});
it('should return false when field.options contains invalid option', () => {
expect(isAppBinding({form: {fields: [{name: 'f', options: [{label: 123}]}]}})).toBe(false);
});
it('should return false when field.lookup is invalid call', () => {
expect(isAppBinding({form: {fields: [{name: 'f', lookup: {expand: {app: 99}}}]}})).toBe(false);
});
it('should return true for field with valid static select options', () => {
expect(isAppBinding({
form: {
fields: [{
name: 'color',
options: [{label: 'Red', value: 'red', icon_data: 'icon'}],
}],
},
})).toBe(true);
});
it('should return false when option.label is not string', () => {
expect(isAppBinding({form: {fields: [{name: 'f', options: [{label: 1, value: 'v'}]}]}})).toBe(false);
});
it('should return false when option.value is not string', () => {
expect(isAppBinding({form: {fields: [{name: 'f', options: [{value: true}]}]}})).toBe(false);
});
it('should return false when option.icon_data is not string', () => {
expect(isAppBinding({form: {fields: [{name: 'f', options: [{value: 'v', icon_data: 5}]}]}})).toBe(false);
});
it('should return true for field value as string', () => {
expect(isAppBinding({form: {fields: [{name: 'f', value: 'hello'}]}})).toBe(true);
});
it('should return true for field value as boolean', () => {
expect(isAppBinding({form: {fields: [{name: 'f', value: true}]}})).toBe(true);
});
it('should return true for field value as null', () => {
expect(isAppBinding({form: {fields: [{name: 'f', value: null}]}})).toBe(true);
});
it('should return true for field value as valid AppSelectOption', () => {
expect(isAppBinding({form: {fields: [{name: 'f', value: {label: 'opt', value: 'v'}}]}})).toBe(true);
});
it('should return false for field value as invalid object', () => {
expect(isAppBinding({form: {fields: [{name: 'f', value: {label: 123}}]}})).toBe(false);
});
});