diff --git a/e2e-tests/cypress/tests/integration/channels/interactive_dialog/field_refresh_spec.js b/e2e-tests/cypress/tests/integration/channels/interactive_dialog/field_refresh_spec.js new file mode 100644 index 00000000000..962c78ef33e --- /dev/null +++ b/e2e-tests/cypress/tests/integration/channels/interactive_dialog/field_refresh_spec.js @@ -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'); +} + diff --git a/e2e-tests/cypress/tests/integration/channels/interactive_dialog/full_dialog_spec.js b/e2e-tests/cypress/tests/integration/channels/interactive_dialog/full_dialog_spec.js index 4222e866896..d155832aef8 100644 --- a/e2e-tests/cypress/tests/integration/channels/interactive_dialog/full_dialog_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/interactive_dialog/full_dialog_spec.js @@ -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". diff --git a/e2e-tests/cypress/tests/integration/channels/interactive_dialog/multiform_spec.js b/e2e-tests/cypress/tests/integration/channels/interactive_dialog/multiform_spec.js new file mode 100644 index 00000000000..c35bfd9826d --- /dev/null +++ b/e2e-tests/cypress/tests/integration/channels/interactive_dialog/multiform_spec.js @@ -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'); +} + diff --git a/e2e-tests/cypress/utils/webhook_utils.js b/e2e-tests/cypress/utils/webhook_utils.js index 34d03fb83af..ae85cb27d9f 100644 --- a/e2e-tests/cypress/utils/webhook_utils.js +++ b/e2e-tests/cypress/utils/webhook_utils.js @@ -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, }; diff --git a/e2e-tests/cypress/webhook_serve.js b/e2e-tests/cypress/webhook_serve.js index 4be1ae20bf0..c29c2b9d31f 100644 --- a/e2e-tests/cypress/webhook_serve.js +++ b/e2e-tests/cypress/webhook_serve.js @@ -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, + }, + }); +} diff --git a/server/channels/app/integration_action.go b/server/channels/app/integration_action.go index f64cf353ea9..5b509261cbf 100644 --- a/server/channels/app/integration_action.go +++ b/server/channels/app/integration_action.go @@ -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 } diff --git a/server/i18n/en.json b/server/i18n/en.json index b1b31087f9a..4d2d2c0c6b8 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -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." diff --git a/server/public/model/integration_action.go b/server/public/model/integration_action.go index 5ea0c5ead0e..786a0ce2a88 100644 --- a/server/public/model/integration_action.go +++ b/server/public/model/integration_action.go @@ -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 diff --git a/server/public/model/integration_action_test.go b/server/public/model/integration_action_test.go index c7ae786dd6d..7a64090461f 100644 --- a/server/public/model/integration_action_test.go +++ b/server/public/model/integration_action_test.go @@ -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) + } + }) + } +} diff --git a/webapp/channels/src/components/apps_form/apps_form_component.tsx b/webapp/channels/src/components/apps_form/apps_form_component.tsx index b4edaecc0c4..e1a908400d1 100644 --- a/webapp/channels/src/components/apps_form/apps_form_component.tsx +++ b/webapp/channels/src/components/apps_form/apps_form_component.tsx @@ -91,8 +91,13 @@ export class AppsForm extends React.PureComponent { 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, }; } diff --git a/webapp/channels/src/components/dialog_router/index.ts b/webapp/channels/src/components/dialog_router/index.ts index a5321c4e0d2..a9f5dbe2ee1 100644 --- a/webapp/channels/src/components/dialog_router/index.ts +++ b/webapp/channels/src/components/dialog_router/index.ts @@ -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), diff --git a/webapp/channels/src/components/dialog_router/interactive_dialog_adapter.test.tsx b/webapp/channels/src/components/dialog_router/interactive_dialog_adapter.test.tsx index d744687bcb5..db333cd29be 100644 --- a/webapp/channels/src/components/dialog_router/interactive_dialog_adapter.test.tsx +++ b/webapp/channels/src/components/dialog_router/interactive_dialog_adapter.test.tsx @@ -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( + , + ); + + 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( - , + , ); + // 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[] = [ diff --git a/webapp/channels/src/components/dialog_router/interactive_dialog_adapter.tsx b/webapp/channels/src/components/dialog_router/interactive_dialog_adapter.tsx index 3808a98eba6..1e1667ef064 100644 --- a/webapp/channels/src/components/dialog_router/interactive_dialog_adapter.tsx +++ b/webapp/channels/src/components/dialog_router/interactive_dialog_adapter.tsx @@ -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; + + // Required actions actions: { submitInteractiveDialog: (submission: DialogSubmission) => Promise>; lookupInteractiveDialog: (submission: DialogSubmission) => Promise}>>; }; - - // Enhanced configuration options - conversionOptions?: Partial; } /** @@ -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 { // 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 = {}; + /** * Logging utilities for adapter diagnostics */ @@ -134,14 +145,16 @@ class InteractiveDialogAdapter extends React.PureComponent { }; 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 { }; /** - * 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): 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> => { 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 { 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 { }, }; } 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 { * Enhanced cancel adapter with proper error handling */ private cancelAdapter = async (): Promise => { + // 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 { }; /** - * 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> => { - 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> => { + 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), + }, + }, + }, + }; + } }; /** diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index ba0da72dbdb..2f9d440c63a 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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}", diff --git a/webapp/channels/src/utils/dialog_conversion.test.ts b/webapp/channels/src/utils/dialog_conversion.test.ts index 333304acef7..e9d79a9a475 100644 --- a/webapp/channels/src/utils/dialog_conversion.test.ts +++ b/webapp/channels/src/utils/dialog_conversion.test.ts @@ -462,6 +462,8 @@ describe('dialog_conversion', () => { 'Test description', undefined, undefined, + 'http://example.com', + '', legacyOptions, ); @@ -483,6 +485,8 @@ describe('dialog_conversion', () => { '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[] = [ diff --git a/webapp/channels/src/utils/dialog_conversion.ts b/webapp/channels/src/utils/dialog_conversion.ts index 4afe562bfe6..0d6d3e84226 100644 --- a/webapp/channels/src/utils/dialog_conversion.ts +++ b/webapp/channels/src/utils/dialog_conversion.ts @@ -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 "" values + */ +export function extractPrimitiveValues(values: Record): Record { + const normalized: Record = {}; + + Object.entries(values).forEach(([key, value]) => { + // Skip null, undefined, empty string, and "" values + if (value === null || value === undefined || value === '' || value === '') { + 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 !== ''); + + 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 !== '') { + 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; diff --git a/webapp/platform/types/src/integrations.ts b/webapp/platform/types/src/integrations.ts index 753bc7b86b7..803519b4e66 100644 --- a/webapp/platform/types/src/integrations.ts +++ b/webapp/platform/types/src/integrations.ts @@ -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; + type?: string; + form?: Dialog; };