mattermost-mobile-upstream/app/utils/dialog_utils.ts
Jack Benning 00a40b4c72 Add collapsible section support to interactive dialogs
Mobile had no support for the `collapsible` dialog element: a dialog
using one rendered none of its fields, because the container itself
carries no value and every lookup walked only the top level.

Conversion gains `flattenCollapsible` / `flattenDialogElements` /
`flattenAppFields`, named to match the webapp's helpers. Every path that
resolves a value by field name — submission, `collectFileIds`,
`mergeFileFieldValues`, refresh-source detection, dynamic-select lookup
in the dialog router, and `initValues` — now walks the flattened list, so
a nested field behaves exactly like a top-level one.

`CollapsibleSection` renders the chevron and title. Children unmount
while collapsed; their values live in the form's reducer, so nothing
typed is lost by closing a section, and a value typed into a section
that is then collapsed still submits.

Tested with 199 unit tests. The most complete of them,
dialog_router_collapsible.test.tsx, drives the real payload from the
demo plugin's `/dialog collapsible` sample through the real adapter,
conversion, form and input components, mocking only the network, and
asserts what the server receives on submit.

E2E coverage is added to the existing demo-plugin suite, against the
`/dialog collapsible` sample already present in the pinned demo plugin
release: each section's initial expanded state, opening a nested
section, a value surviving its section being collapsed, and a nested
value surviving a collapse round trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 14:46:35 -04:00

187 lines
4.9 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Shared utilities for dialog and form handling
/**
* Type guard to check if a value is an AppSelectOption
*/
export function isAppSelectOption(value: any): value is AppSelectOption {
return typeof value === 'object' && value !== null && 'value' in value;
}
/**
* Dialog data source constants
*/
export const DialogDataSources = {
USERS: 'users',
CHANNELS: 'channels',
DYNAMIC: 'dynamic',
} as const;
/**
* Dialog element types
*/
export const DialogElementTypes = {
TEXT: 'text' as const,
TEXTAREA: 'textarea' as const,
SELECT: 'select' as const,
RADIO: 'radio' as const,
BOOL: 'bool' as const,
FILE: 'file' as const,
DATE: 'date' as const,
DATETIME: 'datetime' as const,
COLLAPSIBLE: 'collapsible' as const,
} as const;
/**
* Dialog text subtypes
*/
export const DialogTextSubtypes = {
NUMBER: 'number' as const,
EMAIL: 'email' as const,
PASSWORD: 'password' as const,
URL: 'url' as const,
TEXTAREA: 'textarea' as const,
} as const;
/**
* Maps legacy dialog element types to modern AppField types
*/
export function mapDialogTypeToAppFieldType(dialogType: InteractiveDialogElementType, dataSource?: string): AppFieldType {
switch (dialogType) {
case DialogElementTypes.TEXT:
case DialogElementTypes.TEXTAREA:
return 'text';
case DialogElementTypes.SELECT:
// Handle user and channel selects based on data_source
if (dataSource === DialogDataSources.USERS) {
return 'user';
}
if (dataSource === DialogDataSources.CHANNELS) {
return 'channel';
}
if (dataSource === DialogDataSources.DYNAMIC) {
return 'dynamic_select';
}
return 'static_select';
case DialogElementTypes.RADIO:
return 'radio';
case DialogElementTypes.BOOL:
return 'bool';
case DialogElementTypes.FILE:
return 'file';
case DialogElementTypes.DATE:
return 'date';
case DialogElementTypes.DATETIME:
return 'datetime';
case DialogElementTypes.COLLAPSIBLE:
return 'collapsible';
default:
return 'text';
}
}
/**
* Maps AppField types back to legacy dialog element types
*/
export function mapAppFieldTypeToDialogType(appFieldType: AppFieldType): InteractiveDialogElementType {
switch (appFieldType) {
case 'text':
return DialogElementTypes.TEXT;
case 'static_select':
case 'dynamic_select':
case 'user':
case 'channel':
return DialogElementTypes.SELECT;
case 'radio':
return DialogElementTypes.RADIO;
case 'bool':
return DialogElementTypes.BOOL;
case 'file':
return DialogElementTypes.FILE;
case 'date':
return DialogElementTypes.DATE;
case 'datetime':
return DialogElementTypes.DATETIME;
case 'collapsible':
return DialogElementTypes.COLLAPSIBLE;
default:
return DialogElementTypes.TEXT;
}
}
/**
* Maps AppField type back to data_source for validation
*/
export function getDataSourceForAppFieldType(appFieldType: AppFieldType): string | undefined {
switch (appFieldType) {
case 'user':
return DialogDataSources.USERS;
case 'channel':
return DialogDataSources.CHANNELS;
case 'dynamic_select':
return DialogDataSources.DYNAMIC;
default:
return undefined;
}
}
/**
* Helper to create a DialogElement with proper defaults
*/
export function createDialogElement(
name: string,
type: InteractiveDialogElementType,
options?: Partial<DialogElement>,
): DialogElement {
return {
name,
type,
optional: true,
display_name: name,
...options,
} as DialogElement;
}
/**
* Helper to create an AppField with proper defaults
*/
export function createAppField(
name: string,
type: AppFieldType,
options?: Partial<AppField>,
): AppField {
return {
name,
type,
is_required: false,
label: name,
position: 0,
...options,
};
}
/**
* Validates if a field type supports options
*/
export function supportsOptions(fieldType: InteractiveDialogElementType | AppFieldType): boolean {
const supportedTypes = [
DialogElementTypes.SELECT,
DialogElementTypes.RADIO,
'static_select',
'dynamic_select',
'radio',
'user',
'channel',
];
return supportedTypes.includes(fieldType as any);
}
/**
* Validates if a field type supports data_source
*/
export function supportsDataSource(fieldType: InteractiveDialogElementType): boolean {
return fieldType === DialogElementTypes.SELECT;
}