Add multiform functionality to Interactivedialog (#33076)

* Implement Interactive Dialog field refresh and multi-step form functionality

- Add field refresh capability to interactive dialogs
- Implement multi-step form support
- Add comprehensive E2E tests for new features
- Enhance InteractiveDialogAdapter with server-side error handling
- Optimize form validation and performance
- Add internationalization support for error messages
- Maintain backward compatibility with existing dialogs

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fixes and cleanup

* Enhance field refresh e2e tests with improved field ordering and form submission

- Reverse field order: project name first, then project type (with refresh)
- Ensure all tests enter project name to verify value preservation
- Add form submission to MM-T2540B test to verify complete workflow
- Update webhook server to preserve project name values during refresh
- Add submission handler for field refresh dialog callback
- Update introduction text to reflect new field ordering workflow

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* code review updates

* Simplify apps form value preservation by always merging previous values

- Remove AppFormUpdateType enum and updateType props throughout apps form system
- Simplify getDerivedStateFromProps to always preserve existing values via spread operator
- Remove restoreFormFieldValues function and manual value restoration logic
- Eliminate conditional refresh vs submit behavior in favor of consistent value preservation

This change makes apps form behavior consistent regardless of whether it's a
multi-step submission or field refresh, improving reliability and reducing complexity.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add server-side validation for SubmitDialogResponse

- Add SubmitDialogResponseType enum with OK, Form, Navigate, and Empty types
- Implement IsValid() method with fail-fast validation logic
- Validate type field and ensure Form field consistency based on type
- Add comprehensive test coverage for all validation scenarios
- Integration validates responses in SubmitInteractiveDialog handler

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* lint fixes

* Fix test case for multierror format in Dialog.IsValid()

The Dialog.IsValid() method returns multierror format, so the test
expectation needs to match the actual error format with line breaks.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* i18n-extract

* log dialog errors, allow invalid dialog

* Fix interactive dialog test assertions for undefined values

Handle cases where dialog elements have undefined default values or
placeholders by providing empty string fallbacks. This resolves CI
test failures where undefined values were expected but empty strings
were returned from DOM elements.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Scott Bishel
2025-09-26 11:09:50 -06:00
committed by GitHub
co-authored by Claude Mattermost Build
parent b93245b679
commit f8b3ce4e3b
17 changed files with 1641 additions and 418 deletions
@@ -0,0 +1,221 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Stage: @prod
// Group: @channels @not_cloud @interactive_dialog
/**
* Note: This test requires webhook server running. Initiate `npm run start:webhook` to start.
*/
import * as TIMEOUTS from '../../../fixtures/timeouts';
let createdCommand;
describe('Interactive Dialog - Field Refresh', () => {
before(() => {
cy.shouldNotRunOnCloudEdition();
cy.requireWebhookServer();
// # Ensure that teammate name display setting is set to default 'username'
cy.apiSaveTeammateNameDisplayPreference('username');
// # Create new team and create command on it
cy.apiCreateTeam('test-team', 'Test Team').then(({team}) => {
cy.visit(`/${team.name}`);
const webhookBaseUrl = Cypress.env().webhookBaseUrl;
const command = {
auto_complete: false,
description: 'Test for field refresh functionality',
display_name: 'Field Refresh Dialog Test',
icon_url: '',
method: 'P',
team_id: team.id,
trigger: 'field_refresh_dialog',
url: `${webhookBaseUrl}/dialog/field-refresh`,
username: '',
};
cy.apiCreateCommand(command).then(({data}) => {
createdCommand = data;
});
});
});
afterEach(() => {
// # Reload current page after each test to close any dialogs left open
cy.reload();
});
it('MM-T2540A - Field refresh changes form content within same modal', () => {
// # Post a slash command
cy.postMessage(`/${createdCommand.trigger} `);
// * Verify that the apps form modal opens up
cy.get('#appsModal').should('be.visible').within(() => {
// * Verify initial state - fields are visible in correct order
cy.get('#appsModalLabel').should('contain', 'Field Refresh Demo'); // Modal title stays same during refresh
cy.get('.modal-body').within(() => {
// * Verify initial field refresh dialog fields are present in new order
cy.contains('Project Name').should('be.visible');
cy.contains('Project Type').should('be.visible');
cy.get('.form-group').should('have.length', 2); // project_name + project_type
});
// # Enter project name first
cy.get('input[placeholder*="project name"]').type('Web App Project');
// # Trigger field refresh by changing project type (refresh: true field)
cy.get('.form-group').contains('Project Type').parent().within(() => {
cy.get('[id^=\'MultiInput_\']').click();
});
cy.wait(TIMEOUTS.HALF_SEC);
cy.document().then((doc) => {
cy.wrap(doc).find('.react-select__option').contains('Web Application').click();
});
// * Wait for field refresh to complete
cy.wait(TIMEOUTS.ONE_SEC);
// * Verify modal title stays the same (field refresh, not form submission)
cy.get('#appsModalLabel').should('contain', 'Field Refresh Demo');
// * Verify form has dynamic fields based on project type
cy.get('.modal-body').within(() => {
// * Basic fields should still be visible in correct order
cy.contains('Project Name').should('be.visible');
cy.contains('Project Type').should('be.visible');
// * Project name value should be preserved
cy.get('input[placeholder*="project name"]').should('have.value', 'Web App Project');
// * Selection should be made
cy.get('.react-select__single-value').should('contain', 'Web Application');
// * New framework field should appear for web application
cy.contains('Framework').should('be.visible');
cy.get('.form-group').should('have.length', 3); // project_name + project_type + framework
});
closeAppsFormModal();
});
});
it('MM-T2540B - Field values preserved during refresh and form submits successfully', () => {
// # Post a slash command
cy.postMessage(`/${createdCommand.trigger} `);
cy.get('#appsModal').should('be.visible').within(() => {
// # Fill in initial values
cy.get('input[placeholder*="project name"]').type('My Test Project');
// # Trigger refresh by selecting project type
cy.get('.form-group').contains('Project Type').parent().within(() => {
cy.get('[id^=\'MultiInput_\']').click();
});
cy.wait(TIMEOUTS.HALF_SEC);
cy.document().then((doc) => {
cy.wrap(doc).find('.react-select__option').contains('Mobile App').click();
});
cy.wait(TIMEOUTS.ONE_SEC);
// * Verify project name value is preserved during refresh
cy.get('input[placeholder*="project name"]').should('have.value', 'My Test Project');
// * Verify project type selection is preserved
cy.get('.react-select__single-value').should('contain', 'Mobile App');
// * Verify platform field appeared for mobile app
cy.contains('Platform').should('be.visible');
// # Fill in the platform field and submit
cy.get('.form-group').contains('Platform').parent().within(() => {
cy.get('[id^=\'MultiInput_\']').click();
});
cy.wait(TIMEOUTS.HALF_SEC);
cy.document().then((doc) => {
cy.wrap(doc).find('.react-select__option').contains('React Native').click();
});
// # Submit the form
cy.get('.modal-footer button').contains('Submit').click();
});
// * Verify form was submitted successfully with preserved values
cy.get('.post__body').should('contain', 'Field refresh dialog submitted successfully!');
cy.get('.post__body').should('contain', 'My Test Project');
cy.get('.post__body').should('contain', 'mobile');
cy.get('.post__body').should('contain', 'react-native');
});
it('MM-T2540C - Multiple refresh cycles work correctly', () => {
// # Post a slash command
cy.postMessage(`/${createdCommand.trigger} `);
cy.get('#appsModal').should('be.visible').within(() => {
// # Enter project name first
cy.get('input[placeholder*="project name"]').type('Multi-Test Project');
const projectTypes = [
'Web Application',
'Mobile App',
'API Service',
'Web Application',
];
projectTypes.forEach((projectType) => {
// # Select different project type
cy.get('.form-group').contains('Project Type').parent().within(() => {
cy.get('[id^=\'MultiInput_\']').click();
});
cy.wait(TIMEOUTS.HALF_SEC);
cy.document().then((doc) => {
cy.wrap(doc).find('.react-select__option').contains(projectType).click();
});
cy.wait(TIMEOUTS.ONE_SEC);
// * Verify basic fields are always present in correct order
cy.contains('Project Name').should('be.visible');
cy.contains('Project Type').should('be.visible');
// * Verify project name is preserved through refresh cycles
cy.get('input[placeholder*="project name"]').should('have.value', 'Multi-Test Project');
// * Verify selection was made
cy.get('.react-select__single-value').should('contain', projectType);
// * Verify project-specific fields appear dynamically
if (projectType === 'Web Application') {
cy.contains('Framework').should('be.visible');
cy.get('.form-group').should('have.length', 3);
} else if (projectType === 'Mobile App') {
cy.contains('Platform').should('be.visible');
cy.get('.form-group').should('have.length', 3);
} else if (projectType === 'API Service') {
cy.contains('Language').should('be.visible');
cy.get('.form-group').should('have.length', 3);
}
});
closeAppsFormModal();
});
});
});
function closeAppsFormModal() {
cy.get('.modal-header').should('be.visible').within(($elForm) => {
cy.wrap($elForm).find('button.close').should('be.visible').click();
});
cy.get('#appsModal').should('not.exist');
}
@@ -128,7 +128,7 @@ describe('Interactive Dialog - Apps Form', () => {
cy.get('span').should('have.text', element.placeholder);
});
} else {
cy.get(`#${element.name}`).should('be.visible').and('have.value', element.default).and('have.attr', 'placeholder', element.placeholder);
cy.get(`#${element.name}`).should('be.visible').and('have.value', element.default || '').and('have.attr', 'placeholder', element.placeholder || '');
}
// * Verify that input element are given with the correct type of "input", "email", "number" and "password".
@@ -0,0 +1,258 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Stage: @prod
// Group: @channels @not_cloud @interactive_dialog
/**
* Note: This test requires webhook server running. Initiate `npm run start:webhook` to start.
*/
import * as TIMEOUTS from '../../../fixtures/timeouts';
let createdCommand;
describe('Interactive Dialog - Multiform (Step-by-step Form Submissions)', () => {
before(() => {
cy.shouldNotRunOnCloudEdition();
cy.requireWebhookServer();
// # Ensure that teammate name display setting is set to default 'username'
cy.apiSaveTeammateNameDisplayPreference('username');
// # Create new team and create command on it
cy.apiCreateTeam('test-team', 'Test Team').then(({team}) => {
cy.visit(`/${team.name}`);
const webhookBaseUrl = Cypress.env().webhookBaseUrl;
const command = {
auto_complete: false,
description: 'Test for multiform functionality - step by step form submissions',
display_name: 'Multiform Dialog Test',
icon_url: '',
method: 'P',
team_id: team.id,
trigger: 'multiform_dialog',
url: `${webhookBaseUrl}/dialog/multistep`,
username: '',
};
cy.apiCreateCommand(command).then(({data}) => {
createdCommand = data;
});
});
});
afterEach(() => {
// # Reload current page after each test to close any dialogs left open
cy.reload();
});
it('MM-T2550A - Multiform initial step (Step 1) UI verification', () => {
// # Post a slash command
cy.postMessage(`/${createdCommand.trigger} `);
// * Verify that the apps form modal opens up with Step 1
cy.get('#appsModal').should('be.visible').within(() => {
// * Verify Step 1 dialog structure
cy.get('#appsModalLabel').should('contain', 'Step 1 - Personal Info');
cy.get('#appsModalSubmit').should('contain', 'Next Step');
// * Verify Step 1 fields are present
cy.get('.modal-body').within(() => {
cy.contains('First Name').should('be.visible');
cy.contains('Email').should('be.visible');
cy.get('.form-group').should('have.length', 2);
});
closeAppsFormModal();
});
});
it('MM-T2550B - Complete multiform workflow: Step 1 → Step 2 → Step 3', () => {
// # Post a slash command
cy.postMessage(`/${createdCommand.trigger} `);
// * Verify Step 1 opens
cy.get('#appsModal').should('be.visible').within(() => {
cy.get('#appsModalLabel').should('contain', 'Step 1 - Personal Info');
// # Fill out Step 1 form
cy.get('input[placeholder*="first name"]').type('John');
cy.get('input[placeholder*="email"]').type('john.doe@example.com');
// # Submit Step 1 - this should create a new dialog (Step 2)
cy.get('#appsModalSubmit').click();
});
// * Wait for Step 2 dialog to load (new form created via multiform)
cy.wait(TIMEOUTS.ONE_SEC);
cy.get('#appsModal').should('be.visible').within(() => {
// * Verify new dialog has Step 2 title (multiform created new form)
cy.get('#appsModalLabel').should('contain', 'Step 2 - Work Info');
cy.get('#appsModalSubmit').should('contain', 'Next Step');
// * Verify Step 2 specific fields are present (different from Step 1)
cy.get('.modal-body').within(() => {
cy.contains('Department').should('be.visible');
cy.contains('Experience Level').should('be.visible');
// * Step 1 fields should not be present (this is a new dialog)
cy.contains('First Name').should('not.exist');
cy.contains('Email').should('not.exist');
});
// # Fill out Step 2 form
cy.get('.form-group').contains('Department').parent().within(() => {
cy.get('[id^=\'MultiInput_\']').click();
});
cy.wait(TIMEOUTS.HALF_SEC);
cy.document().then((doc) => {
cy.wrap(doc).find('.react-select__option').contains('Engineering').click();
});
// Select experience level (radio)
cy.get('input[type="radio"][value="senior"]').click();
// # Submit Step 2 - this should create Step 3 dialog
cy.get('#appsModalSubmit').click();
});
// * Wait for Step 3 dialog to load (final step via multiform)
cy.wait(TIMEOUTS.ONE_SEC);
cy.get('#appsModal').should('be.visible').within(() => {
// * Verify new dialog has Step 3 title and final submit
cy.get('#appsModalLabel').should('contain', 'Step 3 - Final Details');
cy.get('#appsModalSubmit').should('contain', 'Complete Registration');
// * Verify Step 3 specific fields
cy.get('.modal-body').within(() => {
cy.contains('Comments').should('be.visible');
cy.contains('Terms & Conditions').should('be.visible');
});
// # Fill out Step 3 (final step)
cy.get('textarea[placeholder*="comments"]').type('Multiform test completed successfully');
cy.get('input[type="checkbox"]').check();
// # Submit final step
cy.get('#appsModalSubmit').click();
});
// * Verify multiform completed and dialog closed
cy.get('#appsModal').should('not.exist');
// * Verify completion message with accumulated data
cy.getLastPost().should('contain', 'Multistep completed successfully');
cy.getLastPost().should('contain', 'Final step values');
});
it('MM-T2550C - Multiform step progression validation', () => {
// # Post a slash command
cy.postMessage(`/${createdCommand.trigger} `);
// * Verify initial Step 1
cy.get('#appsModal').should('be.visible').within(() => {
cy.get('#appsModalLabel').should('contain', 'Step 1 - Personal Info');
// # Submit empty form to test validation
cy.get('#appsModalSubmit').click();
});
cy.wait(TIMEOUTS.HALF_SEC);
// * Verify validation errors appear and we stay on Step 1
cy.get('#appsModal').should('be.visible').within(() => {
cy.get('#appsModalLabel').should('contain', 'Step 1 - Personal Info'); // Still on step 1
// * Check for validation errors on required fields
cy.get('.form-group').contains('First Name').parent().within(() => {
cy.get('.error-text').should('be.visible');
});
cy.get('.form-group').contains('Email').parent().within(() => {
cy.get('.error-text').should('be.visible');
});
closeAppsFormModal();
});
});
it('MM-T2550D - Multiform cancellation at different steps', () => {
// # Post a slash command
cy.postMessage(`/${createdCommand.trigger} `);
// * Test cancellation from Step 1
cy.get('#appsModal').should('be.visible').within(() => {
cy.get('#appsModalLabel').should('contain', 'Step 1 - Personal Info');
cy.get('#appsModalCancel').click();
});
cy.get('#appsModal').should('not.exist');
cy.getLastPost().should('contain', 'Dialog cancelled');
// # Start multiform again and progress to Step 2
cy.postMessage(`/${createdCommand.trigger} `);
cy.get('#appsModal').should('be.visible').within(() => {
cy.get('input[placeholder*="first name"]').type('Jane');
cy.get('input[placeholder*="email"]').type('jane@test.com');
cy.get('#appsModalSubmit').click();
});
// * Wait for Step 2 and cancel from there
cy.wait(TIMEOUTS.ONE_SEC);
cy.get('#appsModal').should('be.visible').within(() => {
cy.get('#appsModalLabel').should('contain', 'Step 2 - Work Info');
cy.get('#appsModalCancel').click();
});
cy.get('#appsModal').should('not.exist');
cy.getLastPost().should('contain', 'Dialog cancelled');
});
it('MM-T2550E - Multiform maintains step-specific content', () => {
// # Post a slash command
cy.postMessage(`/${createdCommand.trigger} `);
// * Verify each step has distinct content that doesn't carry over
cy.get('#appsModal').should('be.visible').within(() => {
// * Step 1 specific elements
cy.get('#appsModalLabel').should('contain', 'Step 1 - Personal Info');
cy.contains('First Name').should('be.visible');
cy.contains('Email').should('be.visible');
// # Fill and submit Step 1
cy.get('input[placeholder*="first name"]').type('Bob');
cy.get('input[placeholder*="email"]').type('bob@company.com');
cy.get('#appsModalSubmit').click();
});
cy.wait(TIMEOUTS.ONE_SEC);
cy.get('#appsModal').should('be.visible').within(() => {
// * Step 2 has completely different content
cy.get('#appsModalLabel').should('contain', 'Step 2 - Work Info');
cy.contains('Department').should('be.visible');
cy.contains('Experience Level').should('be.visible');
// * Step 1 content is not visible (new form)
cy.contains('First Name').should('not.exist');
cy.contains('Email').should('not.exist');
closeAppsFormModal();
});
});
});
function closeAppsFormModal() {
cy.get('.modal-header').should('be.visible').within(($elForm) => {
cy.wrap($elForm).find('button.close').should('be.visible').click();
});
cy.get('#appsModal').should('not.exist');
}
+214 -244
View File
@@ -1,265 +1,231 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
function getFullDialog(triggerId, webhookBaseUrl) {
return {
// Helper function to create dialog base structure
function createDialog(triggerId, webhookBaseUrl, dialogConfig) {
const baseDialog = {
trigger_id: triggerId,
url: `${webhookBaseUrl}/dialog_submit`,
dialog: {
callback_id: 'somecallbackid',
title: 'Title for Full Dialog Test',
icon_url:
'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
elements: [
{
display_name: 'Display Name',
name: 'realname',
type: 'text',
subtype: '',
default: 'default text',
placeholder: 'placeholder',
help_text:
'This a regular input in an interactive dialog triggered by a test integration.',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: null,
},
{
display_name: 'Email',
name: 'someemail',
type: 'text',
subtype: 'email',
default: '',
placeholder: 'placeholder@bladekick.com',
help_text:
'This a regular email input in an interactive dialog triggered by a test integration.',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: null,
},
{
display_name: 'Number',
name: 'somenumber',
type: 'text',
subtype: 'number',
default: '',
placeholder: '',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: null,
},
{
display_name: 'Password',
name: 'somepassword',
type: 'text',
subtype: 'password',
default: 'p@ssW0rd',
placeholder: 'placeholder',
help_text:
'This a password input in an interactive dialog triggered by a test integration.',
optional: true,
min_length: 0,
max_length: 0,
data_source: '',
options: null,
},
{
display_name: 'Display Name Long Text Area',
name: 'realnametextarea',
type: 'textarea',
subtype: '',
default: '',
placeholder: 'placeholder',
help_text: '',
optional: true,
min_length: 5,
max_length: 100,
data_source: '',
options: null,
},
{
display_name: 'User Selector',
name: 'someuserselector',
type: 'select',
subtype: '',
default: '',
placeholder: 'Select a user...',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: 'users',
options: null,
},
{
display_name: 'Channel Selector',
name: 'somechannelselector',
type: 'select',
subtype: '',
default: '',
placeholder: 'Select a channel...',
help_text: 'Choose a channel from the list.',
optional: true,
min_length: 0,
max_length: 0,
data_source: 'channels',
options: null,
},
{
display_name: 'Option Selector',
name: 'someoptionselector',
type: 'select',
subtype: '',
default: '',
placeholder: 'Select an option...',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: [
{
text: 'Option1',
value: 'opt1',
},
{
text: 'Option2',
value: 'opt2',
},
{
text: 'Option3',
value: 'opt3',
},
],
},
{
display_name: 'Radio Option Selector',
name: 'someradiooptions',
type: 'radio',
help_text: '',
optional: false,
options: [
{
text: 'Engineering',
value: 'engineering',
},
{
text: 'Sales',
value: 'sales',
},
],
},
{
display_name: 'Boolean Selector',
placeholder: 'Was this modal helpful?',
name: 'boolean_input',
type: 'bool',
default: 'True',
optional: true,
help_text: 'This is the help text',
},
],
submit_label: 'Submit',
callback_id: dialogConfig.callback_id,
title: dialogConfig.title,
submit_label: dialogConfig.submit_label || 'Submit',
notify_on_cancel: true,
state: 'somestate',
...dialogConfig.dialog_props,
elements: dialogConfig.elements || [],
},
};
if (dialogConfig.icon_url) {
baseDialog.dialog.icon_url = dialogConfig.icon_url;
}
if (dialogConfig.introduction_text) {
baseDialog.dialog.introduction_text = dialogConfig.introduction_text;
}
if (dialogConfig.state) {
baseDialog.dialog.state = dialogConfig.state;
}
if (dialogConfig.source_url) {
baseDialog.dialog.source_url = dialogConfig.source_url;
}
return baseDialog;
}
// Helper function to create form response structure
function createFormResponse(formConfig) {
return {
callback_id: formConfig.callback_id,
title: formConfig.title,
submit_label: formConfig.submit_label || 'Submit',
notify_on_cancel: true,
elements: formConfig.elements || [],
...formConfig.form_props,
};
}
// Helper function to create common form elements
function createElement(type, config) {
const baseElement = {
display_name: config.display_name,
name: config.name,
type,
optional: config.optional || false,
};
if (config.placeholder) {
baseElement.placeholder = config.placeholder;
}
if (config.help_text) {
baseElement.help_text = config.help_text;
}
if (config.default) {
baseElement.default = config.default;
}
if (config.subtype) {
baseElement.subtype = config.subtype;
}
if (config.min_length) {
baseElement.min_length = config.min_length;
}
if (config.max_length) {
baseElement.max_length = config.max_length;
}
if (config.data_source) {
baseElement.data_source = config.data_source;
}
if (config.options) {
baseElement.options = config.options;
}
if (config.refresh) {
baseElement.refresh = config.refresh;
}
return baseElement;
}
// Standard icon URL
const STANDARD_ICON = 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png';
// Dialog configurations
const DIALOG_CONFIGS = {
full: {
callback_id: 'somecallbackid',
title: 'Title for Full Dialog Test',
icon_url: STANDARD_ICON,
elements: [
createElement('text', {display_name: 'Display Name', name: 'realname', default: 'default text', placeholder: 'placeholder', help_text: 'This a regular input in an interactive dialog triggered by a test integration.'}),
createElement('text', {display_name: 'Email', name: 'someemail', subtype: 'email', placeholder: 'placeholder@bladekick.com', help_text: 'This a regular email input in an interactive dialog triggered by a test integration.'}),
createElement('text', {display_name: 'Number', name: 'somenumber', subtype: 'number'}),
createElement('text', {display_name: 'Password', name: 'somepassword', subtype: 'password', default: 'p@ssW0rd', placeholder: 'placeholder', help_text: 'This a password input in an interactive dialog triggered by a test integration.', optional: true}),
createElement('textarea', {display_name: 'Display Name Long Text Area', name: 'realnametextarea', placeholder: 'placeholder', optional: true, min_length: 5, max_length: 100}),
createElement('select', {display_name: 'User Selector', name: 'someuserselector', placeholder: 'Select a user...', data_source: 'users'}),
createElement('select', {display_name: 'Channel Selector', name: 'somechannelselector', placeholder: 'Select a channel...', help_text: 'Choose a channel from the list.', data_source: 'channels', optional: true}),
createElement('select', {display_name: 'Option Selector', name: 'someoptionselector', placeholder: 'Select an option...', options: [{text: 'Option1', value: 'opt1'}, {text: 'Option2', value: 'opt2'}, {text: 'Option3', value: 'opt3'}]}),
createElement('radio', {display_name: 'Radio Option Selector', name: 'someradiooptions', help_text: '', options: [{text: 'Engineering', value: 'engineering'}, {text: 'Sales', value: 'sales'}]}),
createElement('bool', {display_name: 'Boolean Selector', name: 'boolean_input', placeholder: 'Was this modal helpful?', default: 'True', optional: true, help_text: 'This is the help text'}),
],
dialog_props: {state: 'somestate'},
},
simple: {
callback_id: 'somecallbackid',
title: 'Title for Dialog Test without elements',
icon_url: STANDARD_ICON,
submit_label: 'Submit Test',
dialog_props: {state: 'somestate'},
},
userAndChannel: {
callback_id: 'somecallbackid',
title: 'Title for Dialog Test with user and channel element',
icon_url: STANDARD_ICON,
submit_label: 'Submit Test',
elements: [
createElement('select', {display_name: 'User Selector', name: 'someuserselector', placeholder: 'Select a user...', data_source: 'users'}),
createElement('select', {display_name: 'Channel Selector', name: 'somechannelselector', placeholder: 'Select a channel...', help_text: 'Choose a channel from the list.', data_source: 'channels', optional: true}),
],
dialog_props: {state: 'somestate'},
},
boolean: {
callback_id: 'somecallbackid',
title: 'Title for Dialog Test with boolean element',
icon_url: STANDARD_ICON,
submit_label: 'Submit Test',
elements: [
createElement('bool', {display_name: 'Boolean Selector', name: 'boolean_input', placeholder: 'Was this modal helpful?', default: 'True', optional: true, help_text: 'This is the help text'}),
],
dialog_props: {state: 'somestate'},
},
fieldRefresh: {
callback_id: 'field_refresh_callback',
title: 'Field Refresh Demo',
introduction_text: 'Enter project name then select type to see different fields',
elements: [
createElement('text', {display_name: 'Project Name', name: 'project_name', placeholder: 'Enter project name'}),
createElement('select', {display_name: 'Project Type', name: 'project_type', refresh: true, placeholder: 'Select project type...', options: [{text: 'Web Application', value: 'web'}, {text: 'Mobile App', value: 'mobile'}, {text: 'API Service', value: 'api'}]}),
],
},
multistepStep1: {
callback_id: 'multistep_callback',
title: 'Step 1 - Personal Info',
introduction_text: 'Multi-step registration - Step 1 of 3',
submit_label: 'Next Step',
elements: [
createElement('text', {display_name: 'First Name', name: 'first_name', placeholder: 'Enter your first name'}),
createElement('text', {display_name: 'Email', name: 'email', subtype: 'email', placeholder: 'Enter your email address'}),
],
dialog_props: {state: 'step1'},
},
multistepStep2: {
callback_id: 'multistep_callback',
title: 'Step 2 - Work Info',
introduction_text: 'Multi-step registration - Step 2 of 3',
submit_label: 'Next Step',
elements: [
createElement('select', {display_name: 'Department', name: 'department', placeholder: 'Select department...', options: [{text: 'Engineering', value: 'engineering'}, {text: 'Marketing', value: 'marketing'}, {text: 'Sales', value: 'sales'}]}),
createElement('radio', {display_name: 'Experience Level', name: 'experience_level', options: [{text: 'Junior', value: 'junior'}, {text: 'Mid-level', value: 'mid'}, {text: 'Senior', value: 'senior'}]}),
],
form_props: {state: 'step2'},
},
multistepStep3: {
callback_id: 'multistep_callback',
title: 'Step 3 - Final Details',
introduction_text: 'Multi-step registration - Step 3 of 3',
submit_label: 'Complete Registration',
elements: [
createElement('textarea', {display_name: 'Comments', name: 'comments', placeholder: 'Any additional comments...', optional: true}),
createElement('bool', {display_name: 'Terms & Conditions', name: 'terms_accepted'}),
],
form_props: {state: 'step3'},
},
};
// Public API functions
function getFullDialog(triggerId, webhookBaseUrl) {
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.full);
}
function getSimpleDialog(triggerId, webhookBaseUrl) {
return {
trigger_id: triggerId,
url: `${webhookBaseUrl}/dialog_submit`,
dialog: {
callback_id: 'somecallbackid',
title: 'Title for Dialog Test without elements',
icon_url:
'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
submit_label: 'Submit Test',
notify_on_cancel: true,
state: 'somestate',
},
};
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.simple);
}
function getUserAndChannelDialog(triggerId, webhookBaseUrl) {
return {
trigger_id: triggerId,
url: `${webhookBaseUrl}/dialog_submit`,
dialog: {
callback_id: 'somecallbackid',
title: 'Title for Dialog Test with user and channel element',
icon_url:
'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
submit_label: 'Submit Test',
notify_on_cancel: true,
state: 'somestate',
elements: [
{
display_name: 'User Selector',
name: 'someuserselector',
type: 'select',
subtype: '',
default: '',
placeholder: 'Select a user...',
help_text: '',
optional: false,
min_length: 0,
max_length: 0,
data_source: 'users',
options: null,
},
{
display_name: 'Channel Selector',
name: 'somechannelselector',
type: 'select',
subtype: '',
default: '',
placeholder: 'Select a channel...',
help_text: 'Choose a channel from the list.',
optional: true,
min_length: 0,
max_length: 0,
data_source: 'channels',
options: null,
},
],
},
};
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.userAndChannel);
}
function getBooleanDialog(triggerId, webhookBaseUrl) {
return {
trigger_id: triggerId,
url: `${webhookBaseUrl}/dialog_submit`,
dialog: {
callback_id: 'somecallbackid',
title: 'Title for Dialog Test with boolean element',
icon_url:
'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
submit_label: 'Submit Test',
notify_on_cancel: true,
state: 'somestate',
elements: [
{
display_name: 'Boolean Selector',
placeholder: 'Was this modal helpful?',
name: 'boolean_input',
type: 'bool',
default: 'True',
optional: true,
help_text: 'This is the help text',
},
],
},
};
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.boolean);
}
function getFieldRefreshDialog(triggerId, webhookBaseUrl) {
const config = {...DIALOG_CONFIGS.fieldRefresh};
config.source_url = `${webhookBaseUrl}/field_refresh_source`;
return createDialog(triggerId, webhookBaseUrl, config);
}
function getMultistepStep1Dialog(triggerId, webhookBaseUrl) {
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.multistepStep1);
}
function getMultistepStep2Dialog(triggerId, webhookBaseUrl) {
const config = {...DIALOG_CONFIGS.multistepStep2};
config.dialog_props = {url: `${webhookBaseUrl}/dialog_submit`, ...config.form_props};
return createFormResponse(config);
}
function getMultistepStep3Dialog(triggerId, webhookBaseUrl) {
const config = {...DIALOG_CONFIGS.multistepStep3};
config.dialog_props = {url: `${webhookBaseUrl}/dialog_submit`, ...config.form_props};
return createFormResponse(config);
}
function getMultiSelectDialog(triggerId, webhookBaseUrl, includeDefaults = false) {
@@ -405,6 +371,10 @@ module.exports = {
getSimpleDialog,
getUserAndChannelDialog,
getBooleanDialog,
getFieldRefreshDialog,
getMultistepStep1Dialog,
getMultistepStep2Dialog,
getMultistepStep3Dialog,
getMultiSelectDialog,
getDynamicSelectDialog,
};
+152 -3
View File
@@ -29,6 +29,9 @@ server.post('/boolean_dialog_request', onBooleanDialogRequest);
server.post('/multiselect_dialog_request', onMultiSelectDialogRequest);
server.post('/dynamic_select_dialog_request', onDynamicSelectDialogRequest);
server.post('/dynamic_select_source', onDynamicSelectSource);
server.post('/dialog/field-refresh', onFieldRefreshDialogRequest);
server.post('/dialog/multistep', onMultistepDialogRequest);
server.post('/field_refresh_source', onFieldRefreshSource);
server.post('/slack_compatible_message_response', postSlackCompatibleMessageResponse);
server.post('/send_message_to_channel', postSendMessageToChannel);
server.post('/post_outgoing_webhook', postOutgoingWebhook);
@@ -54,6 +57,9 @@ function ping(req, res) {
'POST /multiselect_dialog_request',
'POST /dynamic_select_dialog_request',
'POST /dynamic_select_source',
'POST /dialog/field-refresh',
'POST /dialog/multistep',
'POST /field_refresh_source',
'POST /slack_compatible_message_response',
'POST /send_message_to_channel',
'POST /post_outgoing_webhook',
@@ -281,11 +287,49 @@ function onDialogSubmit(req, res) {
if (body.cancelled) {
message = 'Dialog cancelled';
sendSysadminResponse(message, body.channel_id);
} else {
message = 'Dialog submitted';
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
// Check if this is a multistep submission
if (body.callback_id === 'multistep_callback') {
const currentState = body.state || '';
// Determine next step based on current state
if (currentState === 'step1') {
// Move to step 2
const nextForm = webhookUtils.getMultistepStep2Dialog(null, webhookBaseUrl);
return res.json({
type: 'form',
form: nextForm,
});
} else if (currentState === 'step2') {
// Move to step 3
const nextForm = webhookUtils.getMultistepStep3Dialog(null, webhookBaseUrl);
return res.json({
type: 'form',
form: nextForm,
});
}
// Final step - complete the multistep
const submission = body.submission || {};
message = `Multistep completed successfully! Final step values: ${JSON.stringify(submission, null, 2)}`;
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
// Check if this is a field refresh dialog submission
if (body.callback_id === 'field_refresh_callback') {
const submission = body.submission || {};
message = `Field refresh dialog submitted successfully! Values: ${JSON.stringify(submission, null, 2)}`;
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
// Regular dialog submission
message = 'Dialog submitted';
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
@@ -370,3 +414,108 @@ function postOutgoingWebhook(req, res) {
};
res.status(200).send(response);
}
function onFieldRefreshDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getFieldRefreshDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Field refresh dialog triggered via slash command!'});
}
function onMultistepDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getMultistepStep1Dialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Multistep dialog triggered via slash command!'});
}
function onFieldRefreshSource(req, res) {
const {body} = req;
const submission = body.submission || {};
const projectType = submission.project_type;
const projectName = submission.project_name || '';
res.setHeader('Content-Type', 'application/json');
// Return updated form based on project type selection
const elements = [
{
display_name: 'Project Name',
name: 'project_name',
type: 'text',
placeholder: 'Enter project name',
default: projectName,
optional: false,
},
{
display_name: 'Project Type',
name: 'project_type',
type: 'select',
refresh: true,
placeholder: 'Select project type...',
default: projectType,
options: [
{text: 'Web Application', value: 'web'},
{text: 'Mobile App', value: 'mobile'},
{text: 'API Service', value: 'api'},
],
},
];
// Add different fields based on project type
if (projectType === 'web') {
elements.push({
display_name: 'Framework',
name: 'framework',
type: 'select',
placeholder: 'Select framework...',
options: [
{text: 'React', value: 'react'},
{text: 'Vue', value: 'vue'},
{text: 'Angular', value: 'angular'},
],
});
} else if (projectType === 'mobile') {
elements.push({
display_name: 'Platform',
name: 'platform',
type: 'select',
placeholder: 'Select platform...',
options: [
{text: 'iOS', value: 'ios'},
{text: 'Android', value: 'android'},
{text: 'React Native', value: 'react-native'},
],
});
} else if (projectType === 'api') {
elements.push({
display_name: 'Language',
name: 'language',
type: 'select',
placeholder: 'Select language...',
options: [
{text: 'Go', value: 'go'},
{text: 'Node.js', value: 'nodejs'},
{text: 'Python', value: 'python'},
],
});
}
return res.json({
type: 'form',
form: {
title: 'Field Refresh Demo',
introduction_text: 'Enter project name then select type to see different fields',
submit_label: 'Submit',
elements,
},
});
}
+14 -1
View File
@@ -491,7 +491,11 @@ func (a *App) OpenInteractiveDialog(rctx request.CTX, request model.OpenDialogRe
func (a *App) SubmitInteractiveDialog(rctx request.CTX, request model.SubmitDialogRequest) (*model.SubmitDialogResponse, *model.AppError) {
url := request.URL
request.URL = ""
request.Type = "dialog_submission"
// Preserve Type field for field refresh functionality, otherwise default to dialog_submission
if request.Type != "refresh" {
request.Type = "dialog_submission"
}
b, err := json.Marshal(request)
if err != nil {
@@ -532,6 +536,15 @@ func (a *App) SubmitInteractiveDialog(rctx request.CTX, request model.SubmitDial
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.decode_json_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Validate the response
if err := response.IsValid(); err != nil {
if strings.Contains(err.Error(), "invalid form") {
rctx.Logger().Info("Interactive dialog is invalid", mlog.Err(err))
} else {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.invalid_response", nil, err.Error(), http.StatusBadRequest)
}
}
return &response, nil
}
+4
View File
@@ -7144,6 +7144,10 @@
"id": "app.submit_interactive_dialog.decode_json_error",
"translation": "Encountered an error decoding JSON response from interactive dialog submission."
},
{
"id": "app.submit_interactive_dialog.invalid_response",
"translation": "Encountered an invalid response from interactive dialog submission."
},
{
"id": "app.submit_interactive_dialog.json_error",
"translation": "Encountered an error encoding JSON for the interactive dialog."
+41
View File
@@ -316,6 +316,7 @@ type Dialog struct {
SubmitLabel string `json:"submit_label"`
NotifyOnCancel bool `json:"notify_on_cancel"`
State string `json:"state"`
SourceURL string `json:"source_url,omitempty"`
}
type DialogElement struct {
@@ -333,6 +334,7 @@ type DialogElement struct {
DataSourceURL string `json:"data_source_url,omitempty"`
Options []*PostActionOptions `json:"options"`
MultiSelect bool `json:"multiselect"`
Refresh bool `json:"refresh,omitempty"`
}
type OpenDialogRequest struct {
@@ -353,9 +355,48 @@ type SubmitDialogRequest struct {
Cancelled bool `json:"cancelled"`
}
type SubmitDialogResponseType string
const (
SubmitDialogResponseTypeEmpty SubmitDialogResponseType = ""
SubmitDialogResponseTypeOK SubmitDialogResponseType = "ok"
SubmitDialogResponseTypeForm SubmitDialogResponseType = "form"
SubmitDialogResponseTypeNavigate SubmitDialogResponseType = "navigate"
)
type SubmitDialogResponse struct {
Error string `json:"error,omitempty"`
Errors map[string]string `json:"errors,omitempty"`
Type string `json:"type,omitempty"`
Form *Dialog `json:"form,omitempty"`
}
func (r *SubmitDialogResponse) IsValid() error {
// If Error or Errors are set, this is valid and everything else is ignored
if r.Error != "" || len(r.Errors) > 0 {
return nil
}
// Validate Type field and handle Form field appropriately for each type
switch SubmitDialogResponseType(r.Type) {
case SubmitDialogResponseTypeEmpty, SubmitDialogResponseTypeOK, SubmitDialogResponseTypeNavigate:
// Completion types - Form field should be nil
if r.Form != nil {
return errors.Errorf("form field must be nil for type %q", r.Type)
}
case SubmitDialogResponseTypeForm:
// Continuation type - Form field is required and must be valid
if r.Form == nil {
return errors.New("form field is required for form type")
}
if err := r.Form.IsValid(); err != nil {
return errors.Wrap(err, "invalid form")
}
default:
return errors.Errorf("invalid type %q, must be one of: empty, ok, form, navigate", r.Type)
}
return nil
}
// DialogSelectOption represents an option in a select dropdown for dialogs
@@ -1100,3 +1100,108 @@ func TestIsMultiSelectDefaultInOptions(t *testing.T) {
assert.False(t, result)
})
}
func TestSubmitDialogResponse_IsValid(t *testing.T) {
validDialog := &Dialog{
Title: "Test Dialog",
}
tests := map[string]struct {
response *SubmitDialogResponse
wantErr string
}{
"error takes precedence - with error field": {
response: &SubmitDialogResponse{
Error: "something went wrong",
Type: "invalid_type",
Form: validDialog,
},
wantErr: "",
},
"error takes precedence - with errors field": {
response: &SubmitDialogResponse{
Errors: map[string]string{"field1": "required"},
Type: "invalid_type",
Form: validDialog,
},
wantErr: "",
},
"valid empty type with no form": {
response: &SubmitDialogResponse{
Type: "",
},
wantErr: "",
},
"valid ok type with no form": {
response: &SubmitDialogResponse{
Type: "ok",
},
wantErr: "",
},
"valid navigate type with no form": {
response: &SubmitDialogResponse{
Type: "navigate",
},
wantErr: "",
},
"valid form type with valid form": {
response: &SubmitDialogResponse{
Type: "form",
Form: validDialog,
},
wantErr: "",
},
"invalid empty type with form": {
response: &SubmitDialogResponse{
Type: "",
Form: validDialog,
},
wantErr: "form field must be nil for type \"\"",
},
"invalid ok type with form": {
response: &SubmitDialogResponse{
Type: "ok",
Form: validDialog,
},
wantErr: "form field must be nil for type \"ok\"",
},
"invalid navigate type with form": {
response: &SubmitDialogResponse{
Type: "navigate",
Form: validDialog,
},
wantErr: "form field must be nil for type \"navigate\"",
},
"invalid form type with no form": {
response: &SubmitDialogResponse{
Type: "form",
},
wantErr: "form field is required for form type",
},
"invalid form type with invalid form": {
response: &SubmitDialogResponse{
Type: "form",
Form: &Dialog{}, // Invalid dialog
},
wantErr: "invalid form: 1 error occurred:\n\t* invalid dialog title \"\"",
},
"invalid type": {
response: &SubmitDialogResponse{
Type: "invalid",
},
wantErr: "invalid type \"invalid\", must be one of: empty, ok, form, navigate",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
err := tt.response.IsValid()
if tt.wantErr == "" {
assert.NoError(t, err)
} else {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
}
})
}
}
@@ -91,8 +91,13 @@ export class AppsForm extends React.PureComponent<Props, State> {
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
if (nextProps.form !== prevState.form) {
const values = {
...prevState.values,
...initFormValues(nextProps.form),
};
return {
values: initFormValues(nextProps.form),
values,
form: nextProps.form,
};
}
@@ -37,6 +37,7 @@ function mapStateToProps(state: GlobalState) {
submitLabel: data.dialog.submit_label,
notifyOnCancel: data.dialog.notify_on_cancel,
state: data.dialog.state,
sourceUrl: data.dialog.source_url,
emojiMap,
isAppsFormEnabled,
hasUrl: Boolean(data.url),
@@ -13,6 +13,8 @@ import EmojiMap from 'utils/emoji_map';
import InteractiveDialogAdapter from './interactive_dialog_adapter';
// Mock AppsFormContainer to avoid dynamic import complexity in tests
jest.mock('components/apps_form/apps_form_container', () => {
return {
__esModule: true,
@@ -1286,15 +1288,58 @@ describe('components/interactive_dialog/InteractiveDialogAdapter', () => {
]),
}),
);
});
// Test required field validation
await submitAdapter({
test('should handle missing required values during conversion', async () => {
const requiredElement: DialogElement = {
name: 'required-field',
type: 'text',
display_name: 'Required Field',
default: '',
optional: false,
max_length: 0,
min_length: 0,
help_text: '',
placeholder: '',
subtype: '',
data_source: '',
options: [],
};
const props = {
...baseProps,
elements: [requiredElement],
conversionOptions: {
enhanced: true,
},
actions: {
submitInteractiveDialog: jest.fn().mockResolvedValue({data: {}}),
lookupInteractiveDialog: jest.fn().mockResolvedValue({data: {items: []}}),
},
};
const {getByTestId} = renderWithContext(
<InteractiveDialogAdapter {...props}/>,
);
await waitFor(() => {
expect(getByTestId('apps-form-container')).toBeInTheDocument();
});
// Get the submit adapter function
const mockCall = MockAppsFormContainer.mock.calls[0][0];
const submitAdapter = mockCall.actions.doAppSubmit;
// Test with null value for required field - should not crash
const result = await submitAdapter({
values: {
'text-field': 'valid',
'required-field': null, // Missing required field
},
});
// Should complete successfully (null values are simply skipped)
expect(result.data?.type).toBe('ok');
expect(mockConsole.warn).toHaveBeenCalledWith(
'[InteractiveDialogAdapter]',
'Form submission validation errors',
@@ -1311,66 +1356,21 @@ describe('components/interactive_dialog/InteractiveDialogAdapter', () => {
});
});
describe('No-op Handlers', () => {
test('should provide no-op handlers for unsupported legacy features', async () => {
const props = {
...baseProps,
conversionOptions: {
enhanced: true,
},
};
describe('Dynamic Import Loading', () => {
test('should handle lazy loading with React Suspense', async () => {
// With React.lazy, the component should load asynchronously
// but the test environment with mocking should handle it synchronously
const {getByTestId} = renderWithContext(
<InteractiveDialogAdapter {...props}/>,
<InteractiveDialogAdapter {...baseProps}/>,
);
// Should render the component successfully with mocked AppsFormContainer
await waitFor(() => {
expect(getByTestId('apps-form-container')).toBeInTheDocument();
});
// Get all handlers
const mockCall = MockAppsFormContainer.mock.calls[0][0];
const {
doAppLookup,
doAppFetchForm,
postEphemeralCallResponseForContext,
} = mockCall.actions;
// Test lookup handler returns empty items
const lookupResult = await doAppLookup({
selected_field: 'test_field',
query: 'test',
values: {},
});
expect(lookupResult.data).toEqual({
type: 'ok',
data: {items: []},
});
// Test refresh handler returns ok
const refreshResult = await doAppFetchForm();
expect(refreshResult.data).toEqual({
type: 'ok',
});
// Test ephemeral handler is a no-op function
expect(() => {
postEphemeralCallResponseForContext();
}).not.toThrow();
expect(typeof postEphemeralCallResponseForContext).toBe('function');
// Should log warnings about unsupported features
expect(mockConsole.warn).toHaveBeenCalledWith(
'[InteractiveDialogAdapter]',
'Unexpected refresh call in Interactive Dialog adapter - this should not happen',
'',
);
});
});
describe('Dynamic Import Loading', () => {
});
describe('Advanced Validation Scenarios', () => {
test('should handle element max_length constraints for different field types', async () => {
const elementsWithInvalidLengths: DialogElement[] = [
@@ -14,8 +14,10 @@ import {makeAsyncComponent} from 'components/async_load';
import {createCallContext} from 'utils/apps';
import {
convertDialogToAppForm,
convertAppFormValuesToDialogSubmission,
convertDialogToAppForm,
convertServerDialogResponseToAppForm,
extractPrimitiveValues,
type ConversionOptions,
type ValidationError,
} from 'utils/dialog_conversion';
@@ -25,30 +27,33 @@ import type {DoAppCallResult} from 'types/apps';
const AppsFormContainer = makeAsyncComponent('AppsFormContainer', React.lazy(() => import('components/apps_form/apps_form_container')));
type ConversionContext = ConversionOptions;
// Enhanced Props interface with better type safety
interface Props extends WrappedComponentProps {
// Legacy InteractiveDialog props (now properly typed)
// Core dialog properties
elements?: DialogElement[];
title?: string;
introductionText?: string;
iconUrl?: string;
submitLabel?: string;
// Dialog behavior
url?: string;
callbackId?: string;
state?: string;
notifyOnCancel?: boolean;
emojiMap?: EmojiMap;
onExited?: () => void;
// Enhanced functionality
sourceUrl?: string; // Optional URL for form refresh functionality
emojiMap?: EmojiMap;
conversionOptions?: Partial<ConversionOptions>;
// Required actions
actions: {
submitInteractiveDialog: (submission: DialogSubmission) => Promise<ActionResult<SubmitDialogResponse>>;
lookupInteractiveDialog: (submission: DialogSubmission) => Promise<ActionResult<{items: Array<{text: string; value: string}>}>>;
};
// Enhanced configuration options
conversionOptions?: Partial<ConversionContext>;
}
/**
@@ -70,7 +75,7 @@ interface Props extends WrappedComponentProps {
* elements={dialogElements}
* title="Sample Dialog"
* actions={{ submitInteractiveDialog }}
* onExited={() => console.log('Dialog closed')}
* onExited={() => handleDialogClose()}
* />
*
* // Enhanced mode with full validation for new implementations
@@ -81,17 +86,23 @@ interface Props extends WrappedComponentProps {
* enhanced: true
* }}
* actions={{ submitInteractiveDialog }}
* onExited={() => console.log('Dialog closed')}
* onExited={() => handleDialogClose()}
* />
* ```
*/
class InteractiveDialogAdapter extends React.PureComponent<Props> {
// Default conversion context - enhanced mode disabled for backwards compatibility
private readonly conversionContext: ConversionContext = {
private readonly conversionContext: ConversionOptions = {
enhanced: false, // Legacy mode: minimal validation, non-blocking errors
...this.props.conversionOptions,
};
// Track current dialog elements for validation
private currentDialogElements: DialogElement[] | undefined;
// Accumulate form values across multiform steps (client-side accumulation)
private accumulatedValues: Record<string, any> = {};
/**
* Logging utilities for adapter diagnostics
*/
@@ -134,14 +145,16 @@ class InteractiveDialogAdapter extends React.PureComponent<Props> {
};
private convertToAppForm = (): {form?: AppForm; error?: string} => {
const {elements, title, introductionText, iconUrl, submitLabel} = this.props;
const {elements, title, introductionText, iconUrl, submitLabel, sourceUrl, state} = this.props;
this.currentDialogElements = elements;
const {form, errors} = convertDialogToAppForm(
elements,
title,
introductionText,
iconUrl,
submitLabel,
sourceUrl || '',
state || '',
this.conversionContext,
);
@@ -154,31 +167,116 @@ class InteractiveDialogAdapter extends React.PureComponent<Props> {
};
/**
* Enhanced submission adapter with comprehensive input validation and sanitization
* Common logic for processing form values - used by both submit and refresh
*/
private processFormValues = (currentValues: Record<string, any>): void => {
// Normalize current values to extract primitive values from select objects
const normalizedCurrentValues = extractPrimitiveValues(currentValues);
// Accumulate values: merge current with existing accumulated values
this.accumulatedValues = {
...this.accumulatedValues, // Previous steps' values (including other pages)
...normalizedCurrentValues, // Current form normalized values
};
};
/**
* Common error handling for dialog submission responses
*/
private handleSubmissionError = (result: any, errorId: string, defaultMessage: string) => {
// Handle server-side validation errors
if (result?.data?.error || result?.data?.errors) {
return {
error: {
type: 'error' as const,
text: result.data.error || this.props.intl.formatMessage({
id: `${errorId}_validation`,
defaultMessage: `${defaultMessage} with validation errors`,
}),
data: {
errors: result.data.errors || {},
},
},
};
}
// Handle network/action-level errors
if (result?.error) {
return {
error: {
type: 'error' as const,
text: this.props.intl.formatMessage({
id: errorId,
defaultMessage,
}),
data: {
errors: {},
},
},
};
}
return null; // No error
};
/**
* Common logic for converting server dialog response to AppForm
*/
private convertServerResponseToForm = (serverForm: any) => {
const {form, errors} = convertServerDialogResponseToAppForm(serverForm, this.conversionContext);
// Update current elements for reference
this.currentDialogElements = form.fields?.map((field) => ({
name: field.name,
type: field.type === 'static_select' ? 'select' : field.type,
display_name: field.label,
} as any)) || [];
// Handle validation errors if any
if (errors.length > 0) {
this.logWarn('Form conversion validation errors', {
errorCount: errors.length,
errors,
});
}
return form;
};
/**
* Enhanced submission adapter
*/
private submitAdapter = async (call: AppCallRequest): Promise<DoAppCallResult<unknown>> => {
try {
// Validate and convert AppCallRequest values back to legacy format
const values = call.values || {};
const {submission: convertedValues, errors} = convertAppFormValuesToDialogSubmission(
values,
this.props.elements,
this.conversionContext,
);
// Process and accumulate form values
const currentValues = call.values || {};
this.processFormValues(currentValues);
// Handle validation errors if any
if (errors.length > 0) {
this.logWarn('Form submission validation errors', {
errorCount: errors.length,
errors,
});
// For final submission, use accumulated normalized values
const finalSubmission = this.accumulatedValues;
const dialogState = call.state || this.props.state || '';
// Validate final submission if we have dialog elements
if (this.currentDialogElements && this.conversionContext.enhanced) {
const {errors} = convertAppFormValuesToDialogSubmission(
finalSubmission,
this.currentDialogElements,
this.conversionContext,
);
if (errors.length > 0) {
this.logWarn('Form submission validation errors', {
errorCount: errors.length,
errors,
});
}
}
const legacySubmission: DialogSubmission = {
url: this.props.url || '',
callback_id: this.props.callbackId || '',
state: this.props.state || '',
submission: convertedValues as {[x: string]: string},
state: dialogState, // Dialog state for multiform step tracking
submission: finalSubmission as {[x: string]: string},
user_id: '', // Populated by submitInteractiveDialog action
channel_id: '', // Populated by submitInteractiveDialog action
team_id: '', // Populated by submitInteractiveDialog action
@@ -187,39 +285,26 @@ class InteractiveDialogAdapter extends React.PureComponent<Props> {
const result = await this.props.actions.submitInteractiveDialog(legacySubmission);
// Handle server-side validation errors from the response data (like original dialog)
if (result?.data?.error || result?.data?.errors) {
// Handle errors using common error handler
const errorResult = this.handleSubmissionError(result, 'interactive_dialog.submission_failed', 'Submission failed');
if (errorResult) {
return errorResult;
}
// Check if the response contains a new form (multi-step functionality)
if (result?.data?.type === 'form' && result?.data?.form) {
const form = this.convertServerResponseToForm(result.data.form);
return {
error: {
type: 'error' as const,
text: result.data.error || this.props.intl.formatMessage({
id: 'interactive_dialog.submission_failed_validation',
defaultMessage: 'Submission failed with validation errors',
}),
data: {
errors: result.data.errors || {},
},
data: {
type: 'form' as const,
form,
},
};
}
// Handle network/action-level errors
if (result?.error) {
return {
error: {
type: 'error' as const,
text: this.props.intl.formatMessage({
id: 'interactive_dialog.submission_failed',
defaultMessage: 'Submission failed',
}),
data: {
errors: {},
},
},
};
}
// Success response
// Success response - clear all accumulated values since multiform is complete
this.accumulatedValues = {};
return {
data: {
type: 'ok' as const,
@@ -227,6 +312,9 @@ class InteractiveDialogAdapter extends React.PureComponent<Props> {
},
};
} catch (error) {
// Clear all accumulated values on error to avoid state leakage
this.accumulatedValues = {};
this.logError('Dialog submission failed', {
error: error instanceof Error ? error.message : String(error),
callbackId: this.props.callbackId,
@@ -253,6 +341,9 @@ class InteractiveDialogAdapter extends React.PureComponent<Props> {
* Enhanced cancel adapter with proper error handling
*/
private cancelAdapter = async (): Promise<void> => {
// Clear all accumulated values when dialog is cancelled to avoid state leakage
this.accumulatedValues = {};
if (!this.props.notifyOnCancel) {
return;
}
@@ -421,15 +512,90 @@ class InteractiveDialogAdapter extends React.PureComponent<Props> {
};
/**
* No-op refresh adapter for unsupported legacy feature
* Field refresh adapter for Interactive Dialogs
* Handles form refresh when fields with refresh=true are changed
*/
private refreshOnSelect = async (): Promise<DoAppCallResult<unknown>> => {
this.logWarn('Unexpected refresh call in Interactive Dialog adapter - this should not happen');
return {
data: {
type: 'ok' as const,
},
};
private refreshOnSelect = async (call: AppCallRequest = {} as AppCallRequest): Promise<DoAppCallResult<unknown>> => {
try {
// Check if we have a source URL for field refresh
if (!this.props.sourceUrl) {
this.logWarn('Field refresh requested but no sourceUrl provided', {
fieldName: call.selected_field,
suggestion: 'Add sourceUrl to dialog definition',
});
return {
data: {
type: 'ok' as const,
},
};
}
// Process and accumulate form values (same as submit)
const currentValues = call.values || {};
this.processFormValues(currentValues);
// For refresh, send all accumulated normalized values
const refreshPayload = this.accumulatedValues;
const refreshSubmission: DialogSubmission = {
url: this.props.sourceUrl,
callback_id: this.props.callbackId || '',
state: call.state || this.props.state || '',
submission: refreshPayload as {[x: string]: string}, // Send complete accumulated payload
user_id: '',
channel_id: '',
team_id: '',
cancelled: false,
type: 'refresh', // Indicate this is a field refresh request
};
const result = await this.props.actions.submitInteractiveDialog(refreshSubmission);
// Handle errors using common error handler
const errorResult = this.handleSubmissionError(result, 'interactive_dialog.refresh_failed', 'Field refresh failed');
if (errorResult) {
return errorResult;
}
// Check if the response contains a refreshed form
if (result?.data?.type === 'form' && result?.data?.form) {
const form = this.convertServerResponseToForm(result.data.form);
return {
data: {
type: 'form' as const,
form,
},
};
}
// Default success response (no form changes)
return {
data: {
type: 'ok' as const,
},
};
} catch (error) {
this.logError('Field refresh failed', {
error: error instanceof Error ? error.message : String(error),
fieldName: call?.selected_field || 'unknown',
sourceUrl: this.props.sourceUrl,
});
return {
error: {
type: 'error' as const,
text: error instanceof Error ? error.message : this.props.intl.formatMessage({
id: 'interactive_dialog.refresh_failed',
defaultMessage: 'Field refresh failed',
}),
data: {
errors: {
field_refresh: String(error),
},
},
},
};
}
};
/**
+1 -1
View File
@@ -4563,8 +4563,8 @@
"interactive_dialog.cancel": "Cancel",
"interactive_dialog.element.optional": "(optional)",
"interactive_dialog.lookup_failed": "Lookup failed",
"interactive_dialog.refresh_failed": "Field refresh failed",
"interactive_dialog.submission_failed": "Submission failed",
"interactive_dialog.submission_failed_validation": "Submission failed with validation errors",
"interactive_dialog.submit": "Submit",
"interactive_dialog.submitting": "Submitting...",
"interactive_dialog.validation_failed": "Dialog validation failed: {errors}",
@@ -462,6 +462,8 @@ describe('dialog_conversion', () => {
'Test description',
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
@@ -483,6 +485,8 @@ describe('dialog_conversion', () => {
'<script>alert("xss")</script>Description',
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
@@ -496,6 +500,8 @@ describe('dialog_conversion', () => {
undefined,
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
@@ -511,6 +517,8 @@ describe('dialog_conversion', () => {
undefined,
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
@@ -541,6 +549,8 @@ describe('dialog_conversion', () => {
undefined,
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
@@ -572,6 +582,8 @@ describe('dialog_conversion', () => {
undefined,
undefined,
undefined,
'http://example.com',
'',
enhancedOptions,
);
@@ -590,6 +602,8 @@ describe('dialog_conversion', () => {
undefined,
undefined,
undefined,
'http://example.com',
'',
enhancedOptions,
);
@@ -647,6 +661,8 @@ describe('dialog_conversion', () => {
undefined,
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
@@ -683,6 +699,8 @@ describe('dialog_conversion', () => {
undefined,
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
@@ -718,6 +736,8 @@ describe('dialog_conversion', () => {
undefined,
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
@@ -748,6 +768,8 @@ describe('dialog_conversion', () => {
undefined,
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
@@ -774,6 +796,8 @@ describe('dialog_conversion', () => {
undefined,
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
@@ -782,6 +806,158 @@ describe('dialog_conversion', () => {
expect(form.fields?.[0].type).toBe('dynamic_select');
expect(form.fields?.[0].lookup?.path).toBe('');
});
it('should handle refresh property for select fields', () => {
const elements: DialogElement[] = [
{
name: 'refreshable_select',
type: 'select',
display_name: 'Refreshable Select',
optional: false,
refresh: true,
options: [
{text: 'Option A', value: 'optA'},
{text: 'Option B', value: 'optB'},
],
} as DialogElement,
{
name: 'normal_select',
type: 'select',
display_name: 'Normal Select',
optional: false,
options: [
{text: 'Option X', value: 'optX'},
],
} as DialogElement,
];
const {form, errors} = convertDialogToAppForm(
elements,
'Test Dialog',
undefined,
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
expect(errors).toHaveLength(0);
expect(form.fields).toHaveLength(2);
// Check that refresh property is copied
expect(form.fields?.[0].refresh).toBe(true);
expect(form.fields?.[1].refresh).toBeUndefined();
});
it('should set source property from sourceUrl parameter', () => {
const {form, errors} = convertDialogToAppForm(
[],
'Test Dialog',
undefined,
undefined,
undefined,
'http://example.com/source',
'',
legacyOptions,
);
expect(errors).toHaveLength(0);
expect(form.source).toBeDefined();
expect(form.source?.path).toBe('http://example.com/source');
expect(form.source?.expand).toEqual({});
});
it('should not set source property when sourceUrl is empty and no refresh fields', () => {
const {form, errors} = convertDialogToAppForm(
[],
'Test Dialog',
undefined,
undefined,
undefined,
'', // Empty sourceUrl
'',
legacyOptions,
);
expect(errors).toHaveLength(0);
expect(form.source).toBeUndefined();
});
it('should set default source when refresh fields exist but no sourceUrl', () => {
const elements: DialogElement[] = [
{
name: 'refreshable_select',
type: 'select',
display_name: 'Refreshable Select',
optional: false,
refresh: true,
options: [
{text: 'Option A', value: 'optA'},
],
} as DialogElement,
];
const {form, errors} = convertDialogToAppForm(
elements,
'Test Dialog',
undefined,
undefined,
undefined,
'', // Empty sourceUrl but has refresh fields
'',
legacyOptions,
);
expect(errors).toHaveLength(0);
expect(form.source).toBeDefined();
expect(form.source?.path).toBe('/refresh'); // Default path
expect(form.fields?.[0].refresh).toBe(true);
});
it('should include state in submit and source AppCall objects', () => {
const elements: DialogElement[] = [
{
name: 'test_field',
type: 'text',
display_name: 'Test Field',
optional: false,
refresh: true,
} as DialogElement,
];
const {form, errors} = convertDialogToAppForm(
elements,
'Test Dialog',
undefined,
undefined,
undefined,
'http://example.com/source',
'step1_data', // State parameter
legacyOptions,
);
expect(errors).toHaveLength(0);
expect(form.submit?.state).toBe('step1_data');
expect(form.source?.state).toBe('step1_data');
});
it('should not include undefined state in AppCall objects', () => {
const {form, errors} = convertDialogToAppForm(
[],
'Test Dialog',
undefined,
undefined,
undefined,
'http://example.com/source',
'', // Empty state
legacyOptions,
);
expect(errors).toHaveLength(0);
expect(form.submit?.state).toBeUndefined();
expect(form.source?.state).toBeUndefined();
});
});
describe('convertAppFormValuesToDialogSubmission', () => {
@@ -817,7 +993,7 @@ describe('dialog_conversion', () => {
it('should handle select field values', () => {
const values = {
select_field: {label: 'Option 1', value: 'opt1'},
select_field: 'opt1', // Primitive value (already processed by extractPrimitiveValues)
} as unknown as AppFormValues;
const elements: DialogElement[] = [
@@ -847,10 +1023,7 @@ describe('dialog_conversion', () => {
it('should handle multiselect field values', () => {
const values = {
multiselect_field: [
{label: 'Option 1', value: 'opt1'},
{label: 'Option 3', value: 'opt3'},
],
multiselect_field: ['opt1', 'opt3'], // Primitive values (already processed by extractPrimitiveValues)
} as unknown as AppFormValues;
const elements: DialogElement[] = [
@@ -882,10 +1055,7 @@ describe('dialog_conversion', () => {
it('should validate multiselect field options in enhanced mode', () => {
const values = {
multiselect_field: [
{label: 'Option 1', value: 'opt1'},
{label: 'Invalid Option', value: 'invalid'},
],
multiselect_field: ['opt1', 'invalid'], // Primitive values (already processed by extractPrimitiveValues)
} as unknown as AppFormValues;
const elements: DialogElement[] = [
@@ -919,10 +1089,7 @@ describe('dialog_conversion', () => {
it('should handle multiselect field without options validation', () => {
const values = {
multiselect_field: [
{label: 'User 1', value: 'user1'},
{label: 'User 2', value: 'user2'},
],
multiselect_field: ['user1', 'user2'], // Primitive values (already processed by extractPrimitiveValues)
} as unknown as AppFormValues;
const elements: DialogElement[] = [
@@ -1126,7 +1293,7 @@ describe('dialog_conversion', () => {
it('should validate select field options in enhanced mode', () => {
const values = {
select_field: {label: 'Invalid Option', value: 'invalid'},
select_field: 'invalid', // Primitive value (already processed by extractPrimitiveValues)
} as unknown as AppFormValues;
const elements: DialogElement[] = [
+153 -35
View File
@@ -39,12 +39,37 @@ export type ValidationError = {
code: ValidationErrorCode;
};
// Server dialog response structure
type ServerDialogResponse = {
elements?: DialogElement[];
title?: string;
introduction_text?: string;
icon_url?: string;
submit_label?: string;
source_url?: string;
callback_id?: string;
notify_on_cancel?: boolean;
state?: string;
};
// Transformed dialog props structure
type TransformedDialogProps = {
elements?: DialogElement[];
title: string;
introductionText?: string;
iconUrl?: string;
submitLabel?: string;
sourceUrl?: string;
callbackId?: string;
notifyOnCancel?: boolean;
state?: string;
};
export type ConversionOptions = {
// Enhanced mode enables stricter validation and error handling
// When false: Legacy mode with minimal validation (backwards compatible)
// When true: Enhanced mode with full validation and blocking errors
// TODO: Default to true in v11/v12 and eventually remove this option
enhanced: boolean;
};
@@ -242,9 +267,7 @@ export function getDefaultValue(element: DialogElement): AppFormValue {
if (element.options && element.default) {
// Handle multiselect defaults (comma-separated values)
if (element.type === 'select' && element.multiselect) {
const defaultValues = Array.isArray(element.default) ?
element.default :
String(element.default).split(',').map((val) => val.trim());
const defaultValues = Array.isArray(element.default) ? element.default : String(element.default).split(',').map((val) => val.trim());
const defaultOptions = defaultValues.map((value) => {
const option = element.options!.find((opt) => opt.value === value);
@@ -379,11 +402,34 @@ export function convertElement(element: DialogElement, options: ConversionOption
expand: {},
};
}
// Copy refresh property for dynamic field updates
if (element.refresh !== undefined) {
appField.refresh = element.refresh;
}
}
return {field: appField, errors};
}
/**
* Transform server dialog response format (snake_case) to props format (camelCase)
* Uses the same transformation pattern as mapStateToProps in interactive_dialog/index.tsx
*/
export function transformServerDialogToProps(serverDialog: ServerDialogResponse): TransformedDialogProps {
return {
elements: serverDialog.elements,
title: serverDialog.title || '',
introductionText: serverDialog.introduction_text,
iconUrl: serverDialog.icon_url,
submitLabel: serverDialog.submit_label,
sourceUrl: serverDialog.source_url,
callbackId: serverDialog.callback_id,
notifyOnCancel: serverDialog.notify_on_cancel,
state: serverDialog.state,
};
}
/**
* Convert Interactive Dialog to App Form
*/
@@ -393,10 +439,12 @@ export function convertDialogToAppForm(
introductionText: string | undefined,
iconUrl: string | undefined,
submitLabel: string | undefined,
sourceUrl: string,
dialogState: string,
options: ConversionOptions,
): ConversionResult {
const convertedFields: AppField[] = [];
const allErrors: ValidationError[] = [];
const convertedFields: AppField[] = [];
// Validate title if validation is enabled
if (options.enhanced && !title?.trim()) {
@@ -449,13 +497,90 @@ export function convertDialogToAppForm(
submit: {
path: '/submit',
expand: {},
state: dialogState || undefined,
},
fields: convertedFields,
};
// Set source if sourceUrl is provided or if any fields have refresh enabled
const hasRefreshFields = convertedFields.some((field) => field.refresh === true);
if ((sourceUrl && sourceUrl.trim()) || hasRefreshFields) {
form.source = {
path: sourceUrl || '/refresh',
expand: {},
state: dialogState || undefined,
};
}
return {form, errors: allErrors};
}
/**
* Extract primitive values from form field objects for storage/submission
* Converts select option objects {label: "Text", value: "val"} to primitive "val"
* Filters out null, undefined, empty, and "<nil>" values
*/
export function extractPrimitiveValues(values: Record<string, any>): Record<string, any> {
const normalized: Record<string, any> = {};
Object.entries(values).forEach(([key, value]) => {
// Skip null, undefined, empty string, and "<nil>" values
if (value === null || value === undefined || value === '' || value === '<nil>') {
return;
}
if (Array.isArray(value)) {
// Handle multiselect arrays - extract values from each option object
const extractedValues = value.
filter((item) => item && typeof item === 'object' && 'value' in item).
map((item) => item.value).
filter((val) => val !== null && val !== undefined && val !== '' && val !== '<nil>');
if (extractedValues.length > 0) {
normalized[key] = extractedValues;
}
} else if (value && typeof value === 'object' && 'value' in value) {
// Extract value from single select option object {label: "...", value: "..."}
const extractedValue = value.value;
// Only store if the extracted value is meaningful
if (extractedValue !== null && extractedValue !== undefined && extractedValue !== '' && extractedValue !== '<nil>') {
normalized[key] = extractedValue;
}
} else {
// Keep primitive values as-is (but skip empty/nil values)
normalized[key] = value;
}
});
return normalized;
}
/**
* Convert server dialog response directly to AppForm
* Combines server response transformation with dialog-to-form conversion
*/
export function convertServerDialogResponseToAppForm(
serverResponse: any,
options: ConversionOptions,
): ConversionResult {
// Transform server response format to props format
const transformedDialog = transformServerDialogToProps(serverResponse);
const {form, errors} = convertDialogToAppForm(
transformedDialog.elements,
transformedDialog.title,
transformedDialog.introductionText,
transformedDialog.iconUrl,
transformedDialog.submitLabel,
transformedDialog.sourceUrl || '',
transformedDialog.state || '',
options,
);
return {form, errors};
}
/**
* Convert Apps Form values back to Interactive Dialog submission format
*/
@@ -523,61 +648,54 @@ export function convertAppFormValuesToDialogSubmission(
break;
case DialogElementTypes.SELECT:
// Values are already converted to primitives by extractPrimitiveValues
// Only validate that the values exist in the options list
if (Array.isArray(value)) {
if (element.multiselect) {
// For multiselect, convert array of AppSelectOption to array of values
const multiValues = value.map((item) => {
if (!element.options) {
return item.value;
}
const validOption = element.options.find((opt) => opt.value === item.value);
if (!validOption) {
// Validate each value in multiselect array
const validatedValues = element.options ? value.filter((val) => {
const isValid = element.options!.some((opt) => opt.value === val);
if (!isValid) {
errors.push({
field: element.name,
message: `"${element.name}" field is not valid: Selected value not found in options: ${item.value}`,
message: `"${element.name}" field is not valid: Selected value not found in options: ${val}`,
code: ValidationErrorCode.INVALID_FORMAT,
});
return null;
}
return validOption.value;
}).filter(Boolean);
submission[element.name] = multiValues;
return isValid;
}) : value;
submission[element.name] = validatedValues;
} else {
// For single select with array input, take the first value
// Single select with array - take first value and validate
const firstValue = value[0];
if (firstValue && element.options) {
const validOption = element.options.find((opt) => opt.value === firstValue.value);
if (validOption) {
submission[element.name] = validOption.value;
if (firstValue !== undefined && element.options) {
const isValid = element.options.some((opt) => opt.value === firstValue);
if (isValid) {
submission[element.name] = firstValue;
} else {
errors.push({
field: element.name,
message: `"${element.name}" field is not valid: Selected value not found in options: ${firstValue.value}`,
message: `"${element.name}" field is not valid: Selected value not found in options: ${firstValue}`,
code: ValidationErrorCode.INVALID_FORMAT,
});
submission[element.name] = null;
}
} else {
submission[element.name] = firstValue?.value || null;
submission[element.name] = firstValue || null;
}
}
} else if (typeof value === 'object' && value !== null && 'value' in value) {
// Handle single AppSelectOption
const selectOption = value as AppSelectOption;
if (options.enhanced && element.options) {
const validOption = element.options.find((opt) => opt.value === selectOption.value);
if (!validOption) {
} else {
// Single primitive value - validate if options exist
if (element.options && options.enhanced && value !== null && value !== undefined) {
const isValid = element.options.some((opt) => opt.value === value);
if (!isValid) {
errors.push({
field: element.name,
message: `"${element.name}" field is not valid: Selected value not found in options: ${selectOption.value}`,
message: `"${element.name}" field is not valid: Selected value not found in options: ${value}`,
code: ValidationErrorCode.INVALID_FORMAT,
});
}
}
submission[element.name] = selectOption.value;
} else {
// Handle primitive values
submission[element.name] = value;
}
break;
@@ -156,6 +156,7 @@ type Dialog = {
submit_label?: string;
notify_on_cancel?: boolean;
state?: string;
source_url?: string;
};
export type DialogSubmission = {
@@ -169,6 +170,7 @@ export type DialogSubmission = {
[x: string]: string | string[];
};
cancelled: boolean;
type?: string;
};
export type DialogElement = {
@@ -189,9 +191,12 @@ export type DialogElement = {
text: string;
value: any;
}>;
refresh?: boolean;
};
export type SubmitDialogResponse = {
error?: string;
errors?: Record<string, string>;
type?: string;
form?: Dialog;
};