From f8b3ce4e3bc9eb85d0f998e714ba3ebe9ccfd029 Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Fri, 26 Sep 2025 11:09:50 -0600 Subject: [PATCH] Add multiform functionality to Interactivedialog (#33076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Co-authored-by: Mattermost Build --- .../interactive_dialog/field_refresh_spec.js | 221 +++++++++ .../interactive_dialog/full_dialog_spec.js | 2 +- .../interactive_dialog/multiform_spec.js | 258 ++++++++++ e2e-tests/cypress/utils/webhook_utils.js | 458 ++++++++---------- e2e-tests/cypress/webhook_serve.js | 155 +++++- server/channels/app/integration_action.go | 15 +- server/i18n/en.json | 4 + server/public/model/integration_action.go | 41 ++ .../public/model/integration_action_test.go | 105 ++++ .../apps_form/apps_form_component.tsx | 7 +- .../src/components/dialog_router/index.ts | 1 + .../interactive_dialog_adapter.test.tsx | 106 ++-- .../interactive_dialog_adapter.tsx | 296 ++++++++--- webapp/channels/src/i18n/en.json | 2 +- .../src/utils/dialog_conversion.test.ts | 195 +++++++- .../channels/src/utils/dialog_conversion.ts | 188 +++++-- webapp/platform/types/src/integrations.ts | 5 + 17 files changed, 1641 insertions(+), 418 deletions(-) create mode 100644 e2e-tests/cypress/tests/integration/channels/interactive_dialog/field_refresh_spec.js create mode 100644 e2e-tests/cypress/tests/integration/channels/interactive_dialog/multiform_spec.js 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; };