Add file upload element to interactive dialogs (#36881)

* Add file upload element to interactive dialogs

  Adds a new file element type to interactive dialogs, letting users
  upload files in a dialog and forward the file IDs to the integration.

  Server:
  - model: new file DialogElement type with validation, AllowMultiple
    field, and SubmitDialogRequest.FileIds.
  - SubmitInteractiveDialog validates submitted file IDs (existence +
    ownership) from both file_ids and any file IDs referenced in
    submission values; bounded and batched.
  - client4.getFileInfo / Client4.GetFileInfo.

  Webapp:
  - AppsFormFileUpload component: upload, progress, removal, and
    hydration of pre-set file IDs; single vs allow_multiple selection.
  - Wired into the dialog -> apps-form conversion (file field type).
  - Submit is blocked while any field has an upload in progress
    (per-field pending tracking).

  Tests:
  - Go unit tests for model + submit-time file-ID validation.
  - Jest tests for the upload component.
  - Cypress e2e spec (file_upload_spec.js) + webhook fixtures.

  Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* coderabbit review fixes, linter fixes

* update openAPI spec for file upload

* revert changes to package-lock.json

* review fixes, add correct ids to E2Etests

* fix dryrun security issue

* lint fixes

* Address dialog file upload review feedback and UI file cap

* test fixes

* add several unit tests

* lint fix

---------

Co-authored-by: Scott Bishel <sbishel@ScottsFderalMac.home.local>
This commit is contained in:
Scott Bishel
2026-07-09 21:44:53 -06:00
committed by GitHub
parent ca25611b0c
commit 0d7ae8e58d
24 changed files with 2593 additions and 2 deletions
+8
View File
@@ -132,6 +132,14 @@
cancelled:
type: boolean
description: Set to true if the dialog was cancelled
file_ids:
type: array
items:
type: string
description: >
List of file IDs uploaded as part of the dialog submission.
Each file must have been uploaded by the submitting user. A
maximum of 10 file IDs may be submitted.
description: Dialog submission data
required: true
responses:
@@ -0,0 +1,243 @@
// 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.
*/
const webhookUtils = require('../../../../utils/webhook_utils');
let createdCommand;
let fileUploadDialog;
describe('Interactive Dialog - File Upload', () => {
before(() => {
cy.shouldNotRunOnCloudEdition();
cy.requireWebhookServer();
// # Create new team and create command on it
cy.apiCreateTeam('test-team', 'Test Team').then(({team}) => {
cy.visit(`/${team.name}`);
const webhookBaseUrl = Cypress.expose().webhookBaseUrl;
// # Create slash command that triggers the file upload dialog
const command = {
auto_complete: false,
description: 'Test for file upload dialog elements',
display_name: 'File Upload Dialog Test',
icon_url: '',
method: 'P',
team_id: team.id,
trigger: 'file_upload_dialog',
url: `${webhookBaseUrl}/file_upload_dialog_request`,
username: '',
};
cy.apiCreateCommand(command).then(({data}) => {
createdCommand = data;
fileUploadDialog = webhookUtils.getFileUploadDialog(createdCommand.id, webhookBaseUrl);
});
});
});
afterEach(() => {
// # Reload current page after each test to close any dialogs left open
cy.reload();
});
it('MM-T6070 - Renders file upload dialog with correct labels and buttons', () => {
// # Post the slash command to open the dialog
cy.postMessage(`/${createdCommand.trigger} `);
// * Verify that the apps form modal opens up
cy.get('#appsModal').should('be.visible').within(() => {
// * Verify header contains the correct title
cy.get('.modal-header').should('be.visible').within(() => {
cy.get('#appsModalLabel').should('be.visible').and('have.text', fileUploadDialog.dialog.title);
});
// * Verify both file form-groups are present with correct display names
cy.get('.modal-body').should('be.visible').within(() => {
const singleElement = fileUploadDialog.dialog.elements[0];
const multipleElement = fileUploadDialog.dialog.elements[1];
// * Verify single_document form-group renders with its label
cy.get('.apps-form-file-upload').eq(0).within(() => {
cy.get('label').should('be.visible').and('contain', singleElement.display_name);
// * Verify "Choose File" button for allow_multiple=false
cy.get('button.btn-tertiary').should('be.visible').and('have.text', 'Choose File');
// * Verify placeholder / help text is visible before any upload
cy.get('.help-text').should('be.visible');
});
// * Verify multiple_files form-group renders with its label
cy.get('.apps-form-file-upload').eq(1).within(() => {
cy.get('label').should('be.visible').and('contain', multipleElement.display_name);
// * Verify "Choose Files" button for allow_multiple=true
cy.get('button.btn-tertiary').should('be.visible').and('have.text', 'Choose Files');
// * Verify placeholder / help text is visible before any upload
cy.get('.help-text').should('be.visible');
});
});
// * Verify footer submit label matches the dialog definition
cy.get('.modal-footer').should('be.visible').within(($footer) => {
cy.wrap($footer).find('#appsModalCancel').should('be.visible').and('have.text', 'Cancel');
cy.wrap($footer).find('#appsModalSubmit').should('be.visible').and('have.text', fileUploadDialog.dialog.submit_label);
});
});
// # Close the modal (outside within() so the not.exist check is not vacuous)
closeAppsFormModal();
});
it('MM-T6071 - Uploads files to both required fields and submits successfully', () => {
// # Post the slash command to open the dialog
cy.postMessage(`/${createdCommand.trigger} `);
// * Verify that the apps form modal opens up
cy.get('#appsModal').should('be.visible').within(() => {
// # Upload a file to the single_document field (required, allow_multiple=false)
cy.get('input#single_document').attachFile('png-image-file.png');
// * Verify a completed file preview appears for single_document
// (.post-image__details renders only once the upload has finished)
cy.get('.apps-form-file-upload').eq(0).within(() => {
cy.get('.post-image__details').should('have.length', 1);
});
// # Upload a file to the multiple_files field (required, allow_multiple=true)
cy.get('input#multiple_files').attachFile('small-image.png');
// * Verify a completed file preview appears for multiple_files
cy.get('.apps-form-file-upload').eq(1).within(() => {
cy.get('.post-image__details').should('have.length', 1);
});
// # Wait for the submit button to re-enable. It is disabled while any field
// is interacting (an upload in progress) and re-enables only once uploads
// settle — the same point the uploaded file IDs propagate into the form
// values. Deterministic signal, no fixed wait needed.
cy.get('#appsModalSubmit').should('not.be.disabled');
// # Intercept the dialog submit API call and click Submit
cy.intercept('/api/v4/actions/dialogs/submit').as('submitAction');
cy.get('#appsModalSubmit').click();
});
// * Verify that the apps form modal is closed after successful submission
cy.get('#appsModal').should('not.exist');
// * Verify the submission body contains the correct file ID structure
cy.wait('@submitAction').should('include.all.keys', ['request', 'response']).then((result) => {
const {submission} = result.request.body;
// * Verify single_document is submitted as a string (single file ID)
expect(submission.single_document).to.be.a('string').and.have.length.greaterThan(0);
// * Verify multiple_files is submitted as a string (comma-separated IDs)
expect(submission.multiple_files).to.be.a('string').and.have.length.greaterThan(0);
// * Verify file_ids is an array containing all uploaded file IDs
expect(result.request.body.file_ids).to.be.an('array').and.have.length.greaterThan(0);
const singleId = submission.single_document;
const multipleIds = submission.multiple_files.split(',').filter(Boolean);
// * Verify file_ids contains all the IDs from both fields
expect(result.request.body.file_ids).to.include.members([singleId, ...multipleIds]);
});
// * Verify the success post appears in the channel
cy.getLastPost().should('contain', 'Dialog submitted successfully!');
});
it('MM-T6072 - allow_multiple appends files; single replaces on second selection', () => {
// # Post the slash command to open the dialog
cy.postMessage(`/${createdCommand.trigger} `);
// * Verify that the apps form modal opens up
cy.get('#appsModal').should('be.visible').within(() => {
// # Test allow_multiple=true appends: attach first file to multiple_files
cy.get('input#multiple_files').attachFile('png-image-file.png');
// * Verify one completed preview item appears
cy.get('.apps-form-file-upload').eq(1).within(() => {
cy.get('.post-image__details').should('have.length', 1);
});
// # Attach a second file to multiple_files
cy.get('input#multiple_files').attachFile('small-image.png');
// * Verify two preview items appear (appended, not replaced)
cy.get('.apps-form-file-upload').eq(1).within(() => {
cy.get('.post-image__details').should('have.length', 2);
});
// # Test allow_multiple=false replaces: attach a file to single_document
cy.get('input#single_document').attachFile('png-image-file.png');
// * Verify the first file completed (one preview item)
cy.get('.apps-form-file-upload').eq(0).within(() => {
cy.get('.post-image__details').should('have.length', 1);
});
// # Attach a different file to single_document (should replace, not append)
cy.get('input#single_document').attachFile('small-image.png');
// * Verify only one preview item exists for single_document (replaced)
cy.get('.apps-form-file-upload').eq(0).within(() => {
cy.get('.post-image__details').should('have.length', 1);
});
});
closeAppsFormModal();
});
it('MM-T6073 - Required validation fires when submitting with no files selected', () => {
// # Post the slash command to open the dialog
cy.postMessage(`/${createdCommand.trigger} `);
// * Verify that the apps form modal opens up
cy.get('#appsModal').should('be.visible').within(() => {
// # Submit without uploading any files (both file fields are required).
// Assert the button is interactive first — no uploads are in progress so it
// is enabled immediately; this is a deterministic gate, not a fixed wait.
cy.get('#appsModalSubmit').should('be.enabled').click();
});
// * Verify that the apps form modal is still visible (validation blocked submission)
cy.get('#appsModal').should('be.visible');
// * Verify the inline required error renders on the file field itself,
// consistent with every other apps-form field type (rendered via .error-text)
cy.get('#appsModal').within(() => {
cy.get('.apps-form-file-upload').eq(0).within(() => {
cy.get('.error-text').should('be.visible').and('contain', 'This field is required');
});
});
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');
}
+50
View File
@@ -587,6 +587,55 @@ function getTimezoneManualDialog(triggerId, webhookBaseUrl) {
});
}
function getFileUploadDialog(triggerId, webhookBaseUrl) {
return {
trigger_id: triggerId,
url: `${webhookBaseUrl}/dialog_submit`,
dialog: {
callback_id: 'somecallbackid',
title: 'Title for Dialog Test with file upload element',
icon_url:
'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
submit_label: 'Submit File Upload Test',
notify_on_cancel: true,
state: 'somestate',
elements: [
{
display_name: 'Upload Single Document',
name: 'single_document',
type: 'file',
placeholder: 'Select one document...',
help_text: 'Upload a single document (replaces previous selection).',
optional: false,
},
{
display_name: 'Upload Multiple Files',
name: 'multiple_files',
type: 'file',
allow_multiple: true,
placeholder: 'Select multiple files...',
help_text: 'Upload multiple files (can select and add more).',
optional: false,
},
{
display_name: 'Description',
name: 'description',
type: 'textarea',
subtype: '',
default: '',
placeholder: 'Describe the uploaded files...',
help_text: 'Provide a description for the uploaded files.',
optional: true,
min_length: 0,
max_length: 500,
data_source: '',
options: null,
},
],
},
};
}
function getActionButtonParentDialog(triggerId, webhookBaseUrl) {
const config = {
...DIALOG_CONFIGS.actionButtonParent,
@@ -647,6 +696,7 @@ module.exports = {
getCustomIntervalDialog,
getRelativeDateDialog,
getTimezoneManualDialog,
getFileUploadDialog,
getActionButtonParentDialog,
getActionButtonChildDialog,
};
+21 -1
View File
@@ -28,6 +28,7 @@ server.post('/dialog_submit', onDialogSubmit);
server.post('/boolean_dialog_request', onBooleanDialogRequest);
server.post('/multiselect_dialog_request', onMultiSelectDialogRequest);
server.post('/dynamic_select_dialog_request', onDynamicSelectDialogRequest);
server.post('/file_upload_dialog_request', onFileUploadDialogRequest);
server.post('/dynamic_select_source', onDynamicSelectSource);
server.post('/dialog/field-refresh', onFieldRefreshDialogRequest);
server.post('/dialog/multistep', onMultistepDialogRequest);
@@ -71,6 +72,7 @@ function ping(req, res) {
'POST /boolean_dialog_request',
'POST /multiselect_dialog_request',
'POST /dynamic_select_dialog_request',
'POST /file_upload_dialog_request',
'POST /dynamic_select_source',
'POST /dialog/field-refresh',
'POST /dialog/multistep',
@@ -357,6 +359,17 @@ function onDynamicSelectDialogRequest(req, res) {
return res.json({text: 'Dynamic select dialog triggered via slash command!'});
}
function onFileUploadDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getFileUploadDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'File upload dialog triggered via slash command!'});
}
function onDynamicSelectSource(req, res) {
const {body} = req;
@@ -473,6 +486,7 @@ function onDialogSubmit(req, res) {
let message;
if (body.cancelled) {
message = 'Dialog cancelled';
console.log('[WEBHOOK] Dialog cancelled');
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
@@ -514,7 +528,13 @@ function onDialogSubmit(req, res) {
}
// Regular dialog submission
message = 'Dialog submitted';
// Format submission data for the channel message
const sanitize = (str) => String(str).replace(/[<>&"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
const submissionData = Object.entries(body.submission || {}).
map(([key, value]) => `**${sanitize(key)}**: ${sanitize(value)}`).
join('\n');
message = `Dialog submitted successfully!\n\n**Submission Data:**\n${submissionData}`;
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
+133
View File
@@ -323,6 +323,138 @@ func (a *App) SubmitInteractiveDialog(rctx request.CTX, request model.SubmitDial
url := request.URL
request.URL = ""
// Validate submitted file IDs exist and belong to the submitting user.
// Dedup without sorting so the order the user selected the files is preserved
// when the request is forwarded to the integration.
request.FileIds = model.RemoveDuplicateStringsNonSort(request.FileIds)
if len(request.FileIds) > model.MaxDialogFileIds {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.too_many_file_ids",
map[string]any{"Max": model.MaxDialogFileIds}, "", http.StatusBadRequest)
}
declaredFileIDs := make(map[string]bool, len(request.FileIds))
for _, fileID := range request.FileIds {
declaredFileIDs[fileID] = true
}
// Validate the declared file IDs with a single batched lookup (one DB roundtrip
// instead of one per file). This is the primary ownership check, so a store
// error fails closed.
if len(request.FileIds) > 0 {
declaredFiles, nErr := a.Srv().Store().FileInfo().GetByIds(request.FileIds, false, false, false)
if nErr != nil {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.get_file_info_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
foundFileIDs := make(map[string]bool, len(declaredFiles))
for _, fileInfo := range declaredFiles {
foundFileIDs[fileInfo.Id] = true
if fileInfo.CreatorId != request.UserId {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.file_not_owned", map[string]any{"FileId": fileInfo.Id}, "", http.StatusForbidden)
}
}
// Any declared ID not returned doesn't exist (or was deleted) — reject it.
for _, fileID := range request.FileIds {
if !foundFileIDs[fileID] {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.invalid_file_id", map[string]any{"FileId": fileID}, "", http.StatusBadRequest)
}
}
}
// Defense in depth: integrations read raw field values from request.Submission
// (e.g. submission["my_file"]), not request.FileIds. Because the submit request
// does not carry the dialog's element definitions, the server cannot tell which
// submission fields are file pickers, so a malicious client could smuggle another
// user's file ID through a submission value while sending a benign FileIds list.
// Collect ID-shaped tokens from submission values (recursing into arrays/objects),
// resolve them in a single batch, and enforce the same ownership check on any that
// are real files. Tokens that don't resolve to a file (e.g. user/channel select
// IDs, or ID-shaped free text) are ordinary values and ignored.
//
// The scan is bounded in both breadth (ID-shaped tokens collected) and depth
// (recursion into nested arrays/objects). Hitting either bound must not silently
// skip remaining values — breadth overflow fails closed so a padded submission
// cannot smuggle an unchecked file ID past the cap.
const maxSubmissionScanDepth = 100
candidateFileIDs := make([]string, 0)
seenCandidate := make(map[string]bool)
scanLimitExceeded := false
var collectIDs func(v any, depth int)
collectIDs = func(v any, depth int) {
if depth > maxSubmissionScanDepth || scanLimitExceeded {
return
}
switch typed := v.(type) {
case string:
for tok := range strings.SplitSeq(typed, ",") {
tok = strings.TrimSpace(tok)
if tok == "" || declaredFileIDs[tok] || seenCandidate[tok] || !model.IsValidId(tok) {
continue
}
if len(candidateFileIDs) >= model.MaxDialogSubmissionIDShapedTokenScan {
scanLimitExceeded = true
return
}
seenCandidate[tok] = true
candidateFileIDs = append(candidateFileIDs, tok)
}
case []any:
for _, e := range typed {
if scanLimitExceeded {
return
}
collectIDs(e, depth+1)
}
case []string:
for _, e := range typed {
if scanLimitExceeded {
return
}
collectIDs(e, depth+1)
}
case map[string]any:
for _, e := range typed {
if scanLimitExceeded {
return
}
collectIDs(e, depth+1)
}
}
}
for _, raw := range request.Submission {
if scanLimitExceeded {
break
}
collectIDs(raw, 0)
}
if scanLimitExceeded {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.too_many_submission_ids",
map[string]any{"Max": model.MaxDialogSubmissionIDShapedTokenScan}, "", http.StatusBadRequest)
}
if len(candidateFileIDs) > 0 {
// allowFromCache=false: ownership must be decided from current DB state, not a
// possibly stale per-file cache entry.
submissionFiles, nErr := a.Srv().Store().FileInfo().GetByIds(candidateFileIDs, false, false, false)
if nErr != nil {
// Defense-in-depth scan: a transient store error must not block an otherwise
// valid submission whose tokens may just be coincidental ID-shaped text. The
// primary FileIds ownership check above already ran fail-closed.
rctx.Logger().Warn("Could not resolve submission file IDs for ownership check", mlog.Err(nErr))
} else {
for _, fileInfo := range submissionFiles {
if fileInfo.CreatorId != request.UserId {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.file_not_owned", map[string]any{"FileId": fileInfo.Id}, "", http.StatusForbidden)
}
}
// Only tokens that are real files count toward the limit; combine with the
// declared FileIds so the per-submission ceiling is MaxDialogFileIds total.
if len(declaredFileIDs)+len(submissionFiles) > model.MaxDialogFileIds {
return nil, model.NewAppError("SubmitInteractiveDialog", "app.submit_interactive_dialog.too_many_file_ids",
map[string]any{"Max": model.MaxDialogFileIds}, "", http.StatusBadRequest)
}
}
}
// Preserve Type field for field refresh functionality, otherwise default to dialog_submission
if request.Type != "refresh" {
request.Type = "dialog_submission"
@@ -339,6 +471,7 @@ func (a *App) SubmitInteractiveDialog(rctx request.CTX, request model.SubmitDial
mlog.String("user_id", request.UserId),
mlog.String("channel_id", request.ChannelId),
mlog.String("team_id", request.TeamId),
mlog.Bool("cancelled", request.Cancelled),
)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*a.Config().ServiceSettings.OutgoingIntegrationRequestsTimeout)*time.Second)
@@ -3478,6 +3478,327 @@ func TestPostActionRetainsFromBotAndFromPlugin(t *testing.T) {
assert.Equal(t, "AA", stored.GetProp("A"), "plugin-supplied prop applied")
}
func TestSubmitInteractiveDialogFileValidation(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
})
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := model.SubmitDialogResponse{}
b, _ := json.Marshal(resp)
_, _ = w.Write(b)
}))
defer ts.Close()
baseSubmit := model.SubmitDialogRequest{
URL: ts.URL,
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
TeamId: th.BasicTeam.Id,
CallbackId: "someid",
State: "somestate",
Submission: map[string]any{"name1": "value1"},
}
t.Run("empty FileIds passes validation", func(t *testing.T) {
submit := baseSubmit
submit.FileIds = nil
resp, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
assert.Nil(t, appErr)
require.NotNil(t, resp)
})
t.Run("deduplication happens before count check", func(t *testing.T) {
// Create one valid file
fileInfo := th.CreateFileInfo(t, th.BasicUser.Id, "", th.BasicChannel.Id)
// Build FileIds with the same ID repeated MaxDialogFileIds+1 times.
// After dedup this should be 1 ID, which is within the limit.
ids := make([]string, model.MaxDialogFileIds+1)
for i := range ids {
ids[i] = fileInfo.Id
}
submit := baseSubmit
submit.FileIds = ids
resp, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
assert.Nil(t, appErr)
require.NotNil(t, resp)
})
t.Run("too many file IDs after dedup returns error", func(t *testing.T) {
ids := make([]string, model.MaxDialogFileIds+1)
for i := range ids {
fi := th.CreateFileInfo(t, th.BasicUser.Id, "", th.BasicChannel.Id)
ids[i] = fi.Id
}
submit := baseSubmit
submit.FileIds = ids
_, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
assert.Contains(t, appErr.Id, "too_many_file_ids")
})
t.Run("duplicates that would exceed limit raw but not after dedup passes", func(t *testing.T) {
// Create exactly MaxDialogFileIds unique files
uniqueIds := make([]string, model.MaxDialogFileIds)
for i := range uniqueIds {
fi := th.CreateFileInfo(t, th.BasicUser.Id, "", th.BasicChannel.Id)
uniqueIds[i] = fi.Id
}
// Add a duplicate so raw count is MaxDialogFileIds+1
ids := make([]string, 0, len(uniqueIds)+1)
ids = append(ids, uniqueIds...)
ids = append(ids, uniqueIds[0])
submit := baseSubmit
submit.FileIds = ids
resp, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
assert.Nil(t, appErr)
require.NotNil(t, resp)
})
t.Run("valid file ID owned by submitting user passes", func(t *testing.T) {
fileInfo := th.CreateFileInfo(t, th.BasicUser.Id, "", th.BasicChannel.Id)
submit := baseSubmit
submit.FileIds = []string{fileInfo.Id}
resp, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
assert.Nil(t, appErr)
require.NotNil(t, resp)
})
t.Run("file ID not found returns 400 invalid_file_id", func(t *testing.T) {
submit := baseSubmit
submit.FileIds = []string{model.NewId()}
_, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
assert.Contains(t, appErr.Id, "invalid_file_id")
})
t.Run("file owned by different user returns 403 file_not_owned", func(t *testing.T) {
fileInfo := th.CreateFileInfo(t, th.BasicUser2.Id, "", th.BasicChannel.Id)
submit := baseSubmit
submit.FileIds = []string{fileInfo.Id}
_, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.Contains(t, appErr.Id, "file_not_owned")
})
t.Run("batch with a valid owned file and a missing file returns 400 invalid_file_id", func(t *testing.T) {
// The batched GetByIds returns only the existing file; the not-found ID must
// still be detected by diffing the found set against the requested IDs.
fileInfo := th.CreateFileInfo(t, th.BasicUser.Id, "", th.BasicChannel.Id)
submit := baseSubmit
submit.FileIds = []string{fileInfo.Id, model.NewId()}
_, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
assert.Contains(t, appErr.Id, "invalid_file_id")
})
t.Run("batch with an owned file and another user's file returns 403 file_not_owned", func(t *testing.T) {
// Ownership must be enforced across every file in the batch, not just a single ID.
ownFile := th.CreateFileInfo(t, th.BasicUser.Id, "", th.BasicChannel.Id)
otherFile := th.CreateFileInfo(t, th.BasicUser2.Id, "", th.BasicChannel.Id)
submit := baseSubmit
submit.FileIds = []string{ownFile.Id, otherFile.Id}
_, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.Contains(t, appErr.Id, "file_not_owned")
})
t.Run("unowned file ID smuggled via submission (empty FileIds) is rejected", func(t *testing.T) {
// A client puts another user's file ID in a submission value while sending no
// FileIds. Integrations read submission values, so this must still be blocked.
fileInfo := th.CreateFileInfo(t, th.BasicUser2.Id, "", th.BasicChannel.Id)
submit := baseSubmit
submit.FileIds = nil
submit.Submission = map[string]any{"single_document": fileInfo.Id}
_, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.Contains(t, appErr.Id, "file_not_owned")
})
t.Run("own file ID referenced only via submission passes", func(t *testing.T) {
fileInfo := th.CreateFileInfo(t, th.BasicUser.Id, "", th.BasicChannel.Id)
submit := baseSubmit
submit.FileIds = nil
submit.Submission = map[string]any{"single_document": fileInfo.Id}
resp, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
assert.Nil(t, appErr)
require.NotNil(t, resp)
})
t.Run("ID-shaped submission value that is not a real file is treated as text", func(t *testing.T) {
// A textarea value that happens to be a valid-format ID but isn't a file must
// not block submission.
submit := baseSubmit
submit.FileIds = nil
submit.Submission = map[string]any{"notes": model.NewId()}
resp, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
assert.Nil(t, appErr)
require.NotNil(t, resp)
})
t.Run("many ID-shaped non-file submission values are not rejected (e.g. select fields)", func(t *testing.T) {
// Regression guard: a dialog with more than MaxDialogFileIds ID-shaped values
// that are NOT files (user/channel select IDs) must not trip the file-count cap.
submission := make(map[string]any)
for i := range model.MaxDialogFileIds + 5 {
submission[fmt.Sprintf("select_%d", i)] = model.NewId()
}
submit := baseSubmit
submit.FileIds = nil
submit.Submission = submission
resp, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
assert.Nil(t, appErr)
require.NotNil(t, resp)
})
t.Run("submission ID scan fails closed when breadth cap is exceeded", func(t *testing.T) {
otherFile := th.CreateFileInfo(t, th.BasicUser2.Id, "", th.BasicChannel.Id)
maxTokens := model.MaxDialogSubmissionIDShapedTokenScan
require.Greater(t, maxTokens, 0)
padded := make([]any, 0, maxTokens+1)
for range maxTokens {
padded = append(padded, model.NewId())
}
require.Len(t, padded, maxTokens)
padded = append(padded, otherFile.Id)
submit := baseSubmit
submit.FileIds = nil
submit.Submission = map[string]any{"overflow": padded}
_, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
assert.Contains(t, appErr.Id, "too_many_submission_ids")
})
t.Run("submission ID scan fails closed for comma-separated padding attack", func(t *testing.T) {
otherFile := th.CreateFileInfo(t, th.BasicUser2.Id, "", th.BasicChannel.Id)
maxTokens := model.MaxDialogSubmissionIDShapedTokenScan
require.Greater(t, maxTokens, 0)
var b strings.Builder
for i := range maxTokens {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(model.NewId())
}
b.WriteString(", ")
b.WriteString(otherFile.Id)
submit := baseSubmit
submit.FileIds = nil
submit.Submission = map[string]any{"documents": b.String()}
_, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
assert.Contains(t, appErr.Id, "too_many_submission_ids")
})
t.Run("unowned file ID smuggled via a nested submission map is rejected", func(t *testing.T) {
fileInfo := th.CreateFileInfo(t, th.BasicUser2.Id, "", th.BasicChannel.Id)
submit := baseSubmit
submit.FileIds = nil
submit.Submission = map[string]any{
"metadata": map[string]any{"document": fileInfo.Id},
}
_, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.Contains(t, appErr.Id, "file_not_owned")
})
t.Run("unowned file ID smuggled via comma-separated submission string is rejected", func(t *testing.T) {
ownFile := th.CreateFileInfo(t, th.BasicUser.Id, "", th.BasicChannel.Id)
otherFile := th.CreateFileInfo(t, th.BasicUser2.Id, "", th.BasicChannel.Id)
submit := baseSubmit
submit.FileIds = nil
submit.Submission = map[string]any{
"documents": ownFile.Id + ", " + otherFile.Id,
}
_, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.Contains(t, appErr.Id, "file_not_owned")
})
t.Run("declared and submission file IDs combined cannot exceed MaxDialogFileIds", func(t *testing.T) {
declaredIds := make([]string, model.MaxDialogFileIds/2)
for i := range declaredIds {
fi := th.CreateFileInfo(t, th.BasicUser.Id, "", th.BasicChannel.Id)
declaredIds[i] = fi.Id
}
submissionIds := make([]string, model.MaxDialogFileIds-len(declaredIds)+1)
for i := range submissionIds {
fi := th.CreateFileInfo(t, th.BasicUser.Id, "", th.BasicChannel.Id)
submissionIds[i] = fi.Id
}
submit := baseSubmit
submit.FileIds = declaredIds
submit.Submission = map[string]any{"extra": submissionIds}
_, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
assert.Contains(t, appErr.Id, "too_many_file_ids")
})
t.Run("unowned file ID smuggled via a submission array is rejected", func(t *testing.T) {
fileInfo := th.CreateFileInfo(t, th.BasicUser2.Id, "", th.BasicChannel.Id)
submit := baseSubmit
submit.FileIds = nil
submit.Submission = map[string]any{"attachments": []any{fileInfo.Id}}
_, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.Contains(t, appErr.Id, "file_not_owned")
})
t.Run("deeply nested submission value is depth-bounded and does not exhaust the stack", func(t *testing.T) {
// Built in-memory (not via JSON), so this bypasses the json decoder's own depth
// limit and exercises our explicit recursion guard directly. Nesting far beyond
// the depth cap must be traversed only up to the cap and then ignored — no panic,
// no stack overflow — and the submission still succeeds.
var nested any = model.NewId()
for range 5000 {
nested = []any{nested}
}
submit := baseSubmit
submit.FileIds = nil
submit.Submission = map[string]any{"deep": nested}
resp, appErr := th.App.SubmitInteractiveDialog(th.Context, submit)
assert.Nil(t, appErr)
require.NotNil(t, resp)
})
}
func TestExecuteDialogAction(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
+20
View File
@@ -9082,6 +9082,18 @@
"id": "app.submit_interactive_dialog.decode_json_error",
"translation": "Encountered an error decoding JSON response from interactive dialog submission."
},
{
"id": "app.submit_interactive_dialog.file_not_owned",
"translation": "File {{.FileId}} does not belong to the submitting user."
},
{
"id": "app.submit_interactive_dialog.get_file_info_error",
"translation": "Unable to validate submitted file IDs."
},
{
"id": "app.submit_interactive_dialog.invalid_file_id",
"translation": "File {{.FileId}} is invalid or inaccessible."
},
{
"id": "app.submit_interactive_dialog.invalid_response",
"translation": "Encountered an invalid response from interactive dialog submission."
@@ -9094,6 +9106,14 @@
"id": "app.submit_interactive_dialog.read_body_error",
"translation": "Encountered an error reading response body from interactive dialog submission."
},
{
"id": "app.submit_interactive_dialog.too_many_file_ids",
"translation": "Too many file IDs submitted. Maximum allowed is {{.Max}}."
},
{
"id": "app.submit_interactive_dialog.too_many_submission_ids",
"translation": "Too many ID-shaped values in the submission. Maximum allowed is {{.Max}}."
},
{
"id": "app.system.complete_onboarding_request.app_error",
"translation": "Failed to decode the complete onboarding request."
+43
View File
@@ -40,7 +40,13 @@ const (
DialogElementTextareaMaxLength = 3000
DialogElementSelectMaxLength = 3000
DialogElementBoolMaxLength = 150
DialogElementFileMaxLength = 300
DefaultTimeIntervalMinutes = 60 // Default time interval for DateTime fields
MaxDialogFileIds = 10
// MaxDialogSubmissionIDShapedTokenScan bounds defense-in-depth scanning of
// request.Submission for ID-shaped tokens (file IDs, user/channel select values,
// etc.). This is not the per-dialog file upload limit — see MaxDialogFileIds.
MaxDialogSubmissionIDShapedTokenScan = 256
// Go date/time format constants
ISODateFormat = "2006-01-02" // YYYY-MM-DD
@@ -481,6 +487,7 @@ type DialogElement struct {
DataSourceURL string `json:"data_source_url,omitempty"`
Options []*PostActionOptions `json:"options"`
MultiSelect bool `json:"multiselect"`
AllowMultiple bool `json:"allow_multiple,omitempty"`
Refresh bool `json:"refresh,omitempty"`
// Date/datetime field configuration
@@ -548,6 +555,7 @@ type SubmitDialogRequest struct {
TeamId string `json:"team_id"`
Submission map[string]any `json:"submission"`
Cancelled bool `json:"cancelled"`
FileIds []string `json:"file_ids,omitempty"`
}
type SubmitDialogResponseType string
@@ -777,6 +785,10 @@ func (e *DialogElement) IsValid() error {
multiErr = multierror.Append(multiErr, errors.Errorf("multiselect can only be used with select elements, got type %q", e.Type))
}
if e.AllowMultiple && e.Type != "file" {
multiErr = multierror.Append(multiErr, errors.Errorf("allow_multiple can only be used with file elements, got type %q", e.Type))
}
switch e.Type {
case "text":
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementTextMaxLength))
@@ -856,6 +868,37 @@ func (e *DialogElement) IsValid() error {
}
}
case "file":
multiErr = multierror.Append(multiErr, checkMaxLength("Placeholder", e.Placeholder, DialogElementFileMaxLength))
multiErr = multierror.Append(multiErr, checkMaxLength("Default", e.Default, DialogElementFileMaxLength))
if e.Default != "" {
ids := strings.Split(e.Default, ",")
parsedIds := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if !IsValidId(id) {
multiErr = multierror.Append(multiErr, errors.Errorf("default file ID %q is not a valid ID", id))
continue
}
parsedIds = append(parsedIds, id)
}
if !e.AllowMultiple && len(parsedIds) > 1 {
multiErr = multierror.Append(multiErr, errors.New("default may not contain more than one file ID when allow_multiple is false"))
}
if len(parsedIds) > MaxDialogFileIds {
multiErr = multierror.Append(multiErr, errors.Errorf("default may not contain more than %d file IDs, got %d", MaxDialogFileIds, len(parsedIds)))
}
}
if len(e.Options) > 0 {
multiErr = multierror.Append(multiErr, errors.New("file elements cannot have options"))
}
if e.DataSource != "" {
multiErr = multierror.Append(multiErr, errors.New("file elements cannot have a data source"))
}
case "action_button":
if e.ActionButton == nil {
multiErr = multierror.Append(multiErr, errors.New("action_button element requires action_button configuration"))
@@ -2529,6 +2529,192 @@ func TestMmBlocksContextMap(t *testing.T) {
})
}
func TestDialogElementFileValidation(t *testing.T) {
validFileId := NewId()
validFileId2 := NewId()
validFileId3 := NewId()
tests := map[string]struct {
element *DialogElement
wantErr string
}{
"valid file element with allow_multiple=false and single default ID": {
element: &DialogElement{
DisplayName: "File Upload",
Name: "file_element",
Type: "file",
AllowMultiple: false,
Default: validFileId,
},
wantErr: "",
},
"valid file element with allow_multiple=true and multiple comma-separated IDs": {
element: &DialogElement{
DisplayName: "Multi File Upload",
Name: "multi_file_element",
Type: "file",
AllowMultiple: true,
Default: validFileId + "," + validFileId2 + "," + validFileId3,
},
wantErr: "",
},
"allow_multiple=false with multiple default IDs returns error": {
element: &DialogElement{
DisplayName: "Single File Upload",
Name: "single_file_element",
Type: "file",
AllowMultiple: false,
Default: validFileId + "," + validFileId2,
},
wantErr: "default may not contain more than one file ID when allow_multiple is false",
},
"invalid-format file ID in default returns error": {
element: &DialogElement{
DisplayName: "File Upload",
Name: "file_element",
Type: "file",
AllowMultiple: false,
Default: "invalid-id-format",
},
wantErr: "is not a valid ID",
},
"more than MaxDialogFileIds (10) default IDs returns error": {
element: &DialogElement{
DisplayName: "Multi File Upload",
Name: "multi_file_element",
Type: "file",
AllowMultiple: true,
Default: NewId() + "," + NewId() + "," + NewId() + "," + NewId() + "," + NewId() + "," +
NewId() + "," + NewId() + "," + NewId() + "," + NewId() + "," + NewId() + "," + NewId(),
},
wantErr: "default may not contain more than 10 file IDs",
},
"file element with Options set returns error": {
element: &DialogElement{
DisplayName: "File Upload",
Name: "file_element",
Type: "file",
Options: []*PostActionOptions{
{Text: "Option 1", Value: "opt1"},
},
},
wantErr: "file elements cannot have options",
},
"file element with DataSource set returns error": {
element: &DialogElement{
DisplayName: "File Upload",
Name: "file_element",
Type: "file",
DataSource: "users",
},
wantErr: "file elements cannot have a data source",
},
"allow_multiple=true on non-file element returns error": {
element: &DialogElement{
DisplayName: "Text Element",
Name: "text_element",
Type: "text",
AllowMultiple: true,
},
wantErr: "allow_multiple can only be used with file elements",
},
"placeholder exceeding DialogElementFileMaxLength returns error": {
element: &DialogElement{
DisplayName: "File Upload",
Name: "file_element",
Type: "file",
Placeholder: strings.Repeat("x", DialogElementFileMaxLength+1),
},
wantErr: "Placeholder cannot be longer than 300 characters",
},
"empty default passes validation": {
element: &DialogElement{
DisplayName: "File Upload",
Name: "file_element",
Type: "file",
AllowMultiple: false,
Default: "",
},
wantErr: "",
},
"multiple file IDs with spaces around commas passes": {
element: &DialogElement{
DisplayName: "Multi File Upload",
Name: "multi_file_element",
Type: "file",
AllowMultiple: true,
Default: validFileId + " , " + validFileId2 + " , " + validFileId3,
},
wantErr: "",
},
"placeholder at exactly DialogElementFileMaxLength passes": {
element: &DialogElement{
DisplayName: "File Upload",
Name: "file_element",
Type: "file",
Placeholder: strings.Repeat("x", DialogElementFileMaxLength),
},
wantErr: "",
},
"exactly MaxDialogFileIds (10) default IDs passes": {
element: &DialogElement{
DisplayName: "Multi File Upload",
Name: "multi_file_element",
Type: "file",
AllowMultiple: true,
Default: NewId() + "," + NewId() + "," + NewId() + "," + NewId() + "," + NewId() + "," +
NewId() + "," + NewId() + "," + NewId() + "," + NewId() + "," + NewId(),
},
wantErr: "",
},
"empty strings in comma-separated list are skipped": {
element: &DialogElement{
DisplayName: "Multi File Upload",
Name: "multi_file_element",
Type: "file",
AllowMultiple: true,
Default: "," + validFileId + ",,",
},
wantErr: "",
},
"mixed valid and invalid IDs returns error": {
element: &DialogElement{
DisplayName: "Multi File Upload",
Name: "multi_file_element",
Type: "file",
AllowMultiple: true,
Default: validFileId + ",invalid-id-format",
},
wantErr: "is not a valid ID",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
err := tc.element.IsValid()
if tc.wantErr == "" {
assert.NoError(t, err, name)
} else {
assert.ErrorContains(t, err, tc.wantErr, name)
}
})
}
}
func TestDialogElementIsValid_ActionButton(t *testing.T) {
t.Run("should pass validation with valid action_button element", func(t *testing.T) {
element := DialogElement{
@@ -61,6 +61,11 @@ export type State = {
submitting: string | null;
form: AppForm;
isInteracting: boolean;
// Names of fields with an upload in progress. Submit is blocked while non-empty.
// Tracked per-field (not a single boolean) so concurrent uploads in different file
// fields don't clobber each other's pending state.
uploadingFields: Set<string>;
};
// Helper function to validate date format and warn if datetime format is used
@@ -284,6 +289,7 @@ export class AppsForm extends React.PureComponent<Props, State> {
submitting: null,
form,
isInteracting: false,
uploadingFields: new Set(),
};
}
@@ -349,6 +355,14 @@ export class AppsForm extends React.PureComponent<Props, State> {
handleSubmit = async (e: React.FormEvent, submitName?: string, value?: string) => {
e.preventDefault();
// Block submission while any field has an upload in progress. Submitting now
// would race the upload's onChange and send the form before the uploaded file
// IDs have propagated into the values. The submit button is also disabled in
// this state; this guard covers Enter-key submits.
if (this.state.uploadingFields.size > 0) {
return;
}
const {fields} = this.props.form;
const values = this.state.values;
if (submitName && value) {
@@ -574,6 +588,23 @@ export class AppsForm extends React.PureComponent<Props, State> {
this.setState({isInteracting});
};
// Track per-field upload state. Returning null from the updater when membership is
// unchanged avoids a re-render (and a feedback loop with the field's effect).
setFieldUploading = (fieldName: string, uploading: boolean) => {
this.setState((prev) => {
if (uploading === prev.uploadingFields.has(fieldName)) {
return null;
}
const uploadingFields = new Set(prev.uploadingFields);
if (uploading) {
uploadingFields.add(fieldName);
} else {
uploadingFields.delete(fieldName);
}
return {uploadingFields};
});
};
hasDateTimeFields = (): boolean => {
const {fields} = this.props.form;
return fields ? fields.some((field) =>
@@ -716,6 +747,7 @@ export class AppsForm extends React.PureComponent<Props, State> {
performLookup={this.performLookup}
onChange={this.onChange}
setIsInteracting={this.setIsInteracting}
setFieldUploading={this.setFieldUploading}
listComponent={isEmbedded ? SuggestionList : ModalSuggestionList}
/>
);
@@ -754,6 +786,7 @@ export class AppsForm extends React.PureComponent<Props, State> {
type='submit'
autoFocus={!fields || fields.length === 0}
spinning={Boolean(this.state.submitting)}
disabled={this.state.uploadingFields.size > 0}
spinningText={defineMessage({
id: 'interactive_dialog.submitting',
defaultMessage: 'Submitting...',
@@ -772,6 +805,7 @@ export class AppsForm extends React.PureComponent<Props, State> {
key={o.value}
type='submit'
spinning={this.state.submitting === o.value}
disabled={this.state.uploadingFields.size > 0}
spinningText={o.label}
onClick={(e: React.MouseEvent) => this.handleSubmit(e, field.name, o.value)}
>
@@ -14,6 +14,7 @@ import type {ActionResult} from 'mattermost-redux/types/actions';
import type AutocompleteSelector from 'components/autocomplete_selector';
import Markdown from 'components/markdown';
import ModalSuggestionList from 'components/suggestion/modal_suggestion_list';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import BoolSetting from 'components/widgets/settings/bool_setting';
import RadioSetting from 'components/widgets/settings/radio_setting';
import TextSetting from 'components/widgets/settings/text_setting';
@@ -25,6 +26,8 @@ import AppsFormActionButton from '../apps_form_action_button';
import AppsFormDateField from '../apps_form_date_field';
import AppsFormDateTimeField from '../apps_form_datetime_field';
const AppsFormFileUpload = React.lazy(() => import('components/apps_form/apps_form_file_upload'));
const TEXT_DEFAULT_MAX_LENGTH = 150;
const TEXTAREA_DEFAULT_MAX_LENGTH = 3000;
@@ -37,6 +40,7 @@ export interface Props {
value: AppFormValue;
onChange: (name: string, value: any) => void;
setIsInteracting?: (isInteracting: boolean) => void;
setFieldUploading?: (fieldName: string, uploading: boolean) => void;
autoFocus?: boolean;
listComponent?: React.ComponentProps<typeof AutocompleteSelector>['listComponent'];
performLookup: (name: string, userInput: string) => Promise<AppSelectOption[]>;
@@ -51,6 +55,22 @@ export default class AppsFormField extends React.PureComponent<Props> {
listComponent: ModalSuggestionList,
};
componentWillUnmount() {
// Clear this field's pending-upload flag if it unmounts mid-upload (e.g. a
// multi-step form drops the field) so submit isn't left blocked.
this.props.setFieldUploading?.(this.props.name, false);
}
handleFileSelected = (fileIds: string[]) => {
this.props.onChange(this.props.name, fileIds.join(','));
};
// Stable per-field handler (class property → same reference across renders) so the
// file component's onPendingChange effect dependency doesn't change every render.
handlePendingChange = (uploading: boolean) => {
this.props.setFieldUploading?.(this.props.name, uploading);
};
handleSelected = (selected: AppSelectOption | AppSelectOption[]) => {
const {name, onChange} = this.props;
@@ -115,6 +135,23 @@ export default class AppsFormField extends React.PureComponent<Props> {
}
switch (field.type) {
case AppFieldTypes.FILE: {
return (
<React.Suspense fallback={<LoadingSpinner/>}>
<AppsFormFileUpload
id={name}
label={displayNameContent}
helpText={helpTextContent}
placeholder={placeholder}
onFileSelected={this.handleFileSelected}
onPendingChange={this.handlePendingChange}
disabled={field.readonly}
value={value ? (value as string).split(',').filter(Boolean) : []}
allowMultiple={field.allow_multiple}
/>
</React.Suspense>
);
}
case AppFieldTypes.TEXT: {
const subtype = field.subtype || 'text';
@@ -0,0 +1,44 @@
.apps-form-file-upload {
.file__upload {
display: inline-block;
margin-right: 10px;
input[type="file"] {
display: none;
}
}
&__buttons {
display: flex;
align-items: center;
margin-bottom: 10px;
gap: 10px;
}
&__errors {
margin: 8px 0;
}
&__error-message {
display: flex;
align-items: center;
padding: 2px 0;
color: var(--error-text);
font-size: 0.9em;
gap: 6px;
line-height: 1.3;
word-break: break-word;
word-wrap: break-word;
}
&__error-icon {
color: var(--error-text);
}
.file-preview__container {
display: flex;
height: auto;
flex-wrap: wrap;
margin: 8px 0;
}
}
@@ -0,0 +1,735 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {FileInfo} from '@mattermost/types/files';
import {MaxDialogFileIds} from '@mattermost/types/integrations';
import {renderWithContext, screen, fireEvent, waitFor, act} from 'tests/react_testing_utils';
import AppsFormFileUpload from './apps_form_file_upload';
import type {Props} from './apps_form_file_upload';
// ---- Mocks ----
let mockIdCounter = 0;
jest.mock('utils/utils', () => ({
generateId: () => `stable-id-${mockIdCounter++}`,
}));
const mockUploadFile = jest.fn();
jest.mock('actions/file_actions', () => ({
uploadFile: (params: any) => {
mockUploadFile(params);
return () => {};
},
}));
const mockGetFileInfo = jest.fn();
jest.mock('mattermost-redux/client', () => ({
Client4: {
getFileInfo: (...args: any[]) => mockGetFileInfo(...args),
},
}));
const mockLogError = jest.fn();
jest.mock('mattermost-redux/actions/errors', () => ({
logError: (err: any) => {
mockLogError(err);
return {type: 'LOG_ERROR'};
},
}));
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
getCurrentChannelId: () => 'channel-id-1',
}));
// Mock sub-components to avoid deep rendering
jest.mock('components/file_preview', () => {
return function MockFilePreview(props: any) {
return (
<div data-testid='file-preview'>
{props.fileInfos?.map((fi: any) => (
<div
key={fi.id}
data-testid={`file-preview-item-${fi.id}`}
>
<span>{fi.name}</span>
<button
data-testid={`remove-file-${fi.id}`}
onClick={() => props.onRemove?.(fi.id)}
>
{'Remove'}
</button>
</div>
))}
</div>
);
};
});
jest.mock('components/file_preview/file_progress_preview', () => {
return function MockFileProgressPreview(props: any) {
return (
<div data-testid={`file-progress-${props.clientId}`}>
<span>{props.fileInfo?.name}</span>
<span data-testid={`progress-percent-${props.clientId}`}>{props.fileInfo?.percent}{'%'}</span>
<button
data-testid={`remove-progress-${props.clientId}`}
onClick={() => props.handleRemove(props.clientId)}
>
{'Remove'}
</button>
</div>
);
};
});
// ---- Helpers ----
function makeFileInfo(overrides: Partial<FileInfo> = {}): FileInfo {
return {
id: 'file-info-1',
user_id: 'user-1',
post_id: 'post-1',
channel_id: 'channel-id-1',
create_at: 1000,
update_at: 1000,
delete_at: 0,
name: 'test-file.png',
extension: 'png',
size: 1024,
mime_type: 'image/png',
mini_preview: null,
width: 0,
height: 0,
has_preview_image: false,
clientId: '',
archived: false,
...overrides,
} as FileInfo;
}
function makeFile(name = 'test-file.png', type = 'image/png'): File {
return new File(['file-content'], name, {type});
}
function createFileList(files: File[]): FileList {
const fileList = {
length: files.length,
item: (index: number) => files[index] || null,
} as FileList;
files.forEach((file, i) => {
(fileList as any)[i] = file;
});
return fileList;
}
const baseProps: Props = {
id: 'file-upload-1',
label: 'Upload a file',
onFileSelected: jest.fn(),
onPendingChange: jest.fn(),
};
function renderComponent(overrides: Partial<Props> = {}) {
const props = {...baseProps, ...overrides};
return renderWithContext(<AppsFormFileUpload {...props}/>);
}
// Simulate a file selection on the hidden input
function selectFiles(container: HTMLElement, files: File[]) {
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
const fileList = createFileList(files);
Object.defineProperty(input, 'files', {value: fileList, configurable: true});
fireEvent.change(input);
}
// Extract the callbacks from the most recent mockUploadFile call
function getUploadCallbacks(callIndex = 0) {
const call = mockUploadFile.mock.calls[callIndex]?.[0];
return {
onProgress: call?.onProgress as (info: any) => void,
onSuccess: call?.onSuccess as (response: any) => void,
onError: call?.onError as (err: any) => void,
clientId: call?.clientId as string,
name: call?.name as string,
};
}
// ---- Tests ----
describe('components/apps_form/apps_form_file_upload/AppsFormFileUpload', () => {
beforeEach(() => {
jest.clearAllMocks();
mockIdCounter = 0;
});
describe('happy path', () => {
it('renders label and Choose File button', () => {
renderComponent();
expect(screen.getByText('Upload a file')).toBeVisible();
expect(screen.getByRole('button', {name: /choose file/i})).toBeVisible();
});
it('file selection triggers upload and shows progress', () => {
const {container} = renderComponent();
const file = makeFile();
selectFiles(container, [file]);
expect(mockUploadFile).toHaveBeenCalledTimes(1);
expect(mockUploadFile).toHaveBeenCalledWith(
expect.objectContaining({
file,
name: 'test-file.png',
channelId: 'channel-id-1',
}),
);
});
it('successful upload shows FilePreview and calls onFileSelected', () => {
const onFileSelected = jest.fn();
const {container} = renderComponent({onFileSelected});
const file = makeFile();
selectFiles(container, [file]);
const {onSuccess} = getUploadCallbacks();
const fileInfo = makeFileInfo({id: 'uploaded-file-1', name: 'test-file.png'});
act(() => {
onSuccess({file_infos: [fileInfo]});
});
expect(screen.getByTestId('file-preview')).toBeVisible();
expect(screen.getByTestId('file-preview-item-uploaded-file-1')).toBeVisible();
expect(onFileSelected).toHaveBeenCalledWith(['uploaded-file-1']);
});
});
describe('error paths', () => {
it('upload failure shows error message with file name', () => {
const onFileSelected = jest.fn();
const {container} = renderComponent({onFileSelected});
const file = makeFile('important.pdf');
selectFiles(container, [file]);
const {onError} = getUploadCallbacks();
act(() => {
onError('Network timeout');
});
expect(screen.getByText(/important\.pdf/)).toBeVisible();
expect(screen.getByText(/Network timeout/)).toBeVisible();
});
it('upload failure does NOT call onFileSelected with the failed file', () => {
const onFileSelected = jest.fn();
const {container} = renderComponent({onFileSelected});
selectFiles(container, [makeFile()]);
const {onError} = getUploadCallbacks();
act(() => {
onError('Upload failed');
});
// onFileSelected is called but with empty array (no successful files)
expect(onFileSelected).toHaveBeenCalledWith([]);
});
it('ServerError object extracts message correctly', () => {
const {container} = renderComponent();
selectFiles(container, [makeFile('doc.txt')]);
const {onError} = getUploadCallbacks();
act(() => {
onError({message: 'File too large', server_error_id: 'err.too_large', status_code: 413});
});
expect(screen.getByText(/File too large/)).toBeVisible();
});
it('string error is used directly', () => {
const {container} = renderComponent();
selectFiles(container, [makeFile()]);
const {onError} = getUploadCallbacks();
act(() => {
onError('Direct string error');
});
expect(screen.getByText(/Direct string error/)).toBeVisible();
});
it('i18n fallback used when ServerError has no message', () => {
const {container} = renderComponent();
selectFiles(container, [makeFile()]);
const {onError} = getUploadCallbacks();
act(() => {
onError({server_error_id: 'some.id'} as any);
});
// Falls back to intl defaultMessage 'Upload failed'
expect(screen.getByText(/Upload failed/)).toBeVisible();
});
it('dispatches logError on upload failure with string error', () => {
const {container} = renderComponent();
selectFiles(container, [makeFile()]);
const {onError} = getUploadCallbacks();
act(() => {
onError('Some error string');
});
expect(mockLogError).toHaveBeenCalledWith({message: 'Some error string'});
});
it('dispatches logError on upload failure with ServerError object', () => {
const {container} = renderComponent();
selectFiles(container, [makeFile()]);
const serverErr = {message: 'Server said no', server_error_id: 'err.no', status_code: 500};
const {onError} = getUploadCallbacks();
act(() => {
onError(serverErr);
});
expect(mockLogError).toHaveBeenCalledWith(serverErr);
});
});
describe('edge cases', () => {
it('disabled prop disables the Choose File button', () => {
renderComponent({disabled: true});
expect(screen.getByRole('button', {name: /choose file/i})).toBeDisabled();
});
it('Choose File button disabled while uploading', () => {
const {container} = renderComponent();
selectFiles(container, [makeFile()]);
// After file selected and upload started, button should be disabled
expect(screen.getByRole('button', {name: /choose file/i})).toBeDisabled();
});
it('Choose File button re-enabled after upload completes', () => {
const {container} = renderComponent();
selectFiles(container, [makeFile()]);
expect(screen.getByRole('button', {name: /choose file/i})).toBeDisabled();
const {onSuccess} = getUploadCallbacks();
const fileInfo = makeFileInfo({id: 'done-file'});
act(() => {
onSuccess({file_infos: [fileInfo]});
});
expect(screen.getByRole('button', {name: /choose file/i})).toBeEnabled();
});
it('allowMultiple=false replaces existing files on new selection', () => {
const onFileSelected = jest.fn();
const {container} = renderComponent({allowMultiple: false, onFileSelected});
// Upload first file
selectFiles(container, [makeFile('first.png')]);
const {onSuccess: onSuccess1} = getUploadCallbacks(0);
const fileInfo1 = makeFileInfo({id: 'file-1', name: 'first.png'});
act(() => {
onSuccess1({file_infos: [fileInfo1]});
});
expect(onFileSelected).toHaveBeenLastCalledWith(['file-1']);
// Upload second file — should replace, not append
selectFiles(container, [makeFile('second.png')]);
const {onSuccess: onSuccess2} = getUploadCallbacks(1);
const fileInfo2 = makeFileInfo({id: 'file-2', name: 'second.png'});
act(() => {
onSuccess2({file_infos: [fileInfo2]});
});
expect(onFileSelected).toHaveBeenLastCalledWith(['file-2']);
});
it('allowMultiple=false limits selection to one file per pick', () => {
const {container} = renderComponent({allowMultiple: false});
selectFiles(container, [makeFile('first.png'), makeFile('second.png')]);
expect(mockUploadFile).toHaveBeenCalledTimes(1);
expect(screen.getByText('Uploads limited to 1 files maximum.')).toBeVisible();
});
it('allowMultiple=false hydrates only the first value file ID on mount', async () => {
mockGetFileInfo.mockImplementation((fileId: string) => {
return Promise.resolve(makeFileInfo({id: fileId, name: `${fileId}.png`}));
});
renderComponent({
allowMultiple: false,
value: ['file-a', 'file-b'],
});
await waitFor(() => {
expect(screen.getByTestId('file-preview-item-file-a')).toBeVisible();
});
expect(mockGetFileInfo).toHaveBeenCalledTimes(1);
expect(mockGetFileInfo).toHaveBeenCalledWith('file-a');
expect(screen.queryByTestId('file-preview-item-file-b')).not.toBeInTheDocument();
});
it('allowMultiple=true appends files on new selection', () => {
const onFileSelected = jest.fn();
const {container} = renderComponent({allowMultiple: true, onFileSelected});
// Upload first file
selectFiles(container, [makeFile('first.png')]);
const {onSuccess: onSuccess1} = getUploadCallbacks(0);
const fileInfo1 = makeFileInfo({id: 'file-1', name: 'first.png'});
act(() => {
onSuccess1({file_infos: [fileInfo1]});
});
expect(onFileSelected).toHaveBeenLastCalledWith(['file-1']);
// Upload second file — should append
selectFiles(container, [makeFile('second.png')]);
const {onSuccess: onSuccess2} = getUploadCallbacks(1);
const fileInfo2 = makeFileInfo({id: 'file-2', name: 'second.png'});
act(() => {
onSuccess2({file_infos: [fileInfo2]});
});
expect(onFileSelected).toHaveBeenLastCalledWith(['file-1', 'file-2']);
});
it('allowMultiple=true limits uploads to MaxDialogFileIds', () => {
const onFileSelected = jest.fn();
const {container} = renderComponent({allowMultiple: true, onFileSelected});
const batch = Array.from({length: MaxDialogFileIds + 2}, (_, i) => makeFile(`file-${i}.png`));
selectFiles(container, batch);
expect(mockUploadFile).toHaveBeenCalledTimes(MaxDialogFileIds);
expect(screen.getByText(`Uploads limited to ${MaxDialogFileIds} files maximum.`)).toBeVisible();
});
it('allowMultiple=true disables choose button at MaxDialogFileIds', async () => {
const onFileSelected = jest.fn();
const {container} = renderComponent({allowMultiple: true, onFileSelected});
for (let i = 0; i < MaxDialogFileIds; i++) {
selectFiles(container, [makeFile(`file-${i}.png`)]);
const {onSuccess} = getUploadCallbacks(i);
act(() => {
onSuccess({file_infos: [makeFileInfo({id: `file-id-${i}`, name: `file-${i}.png`})]});
});
}
expect(screen.getByRole('button', {name: /choose files/i})).toBeDisabled();
});
it('onPendingChange(true) called when upload starts, false when done', () => {
const onPendingChange = jest.fn();
const {container} = renderComponent({onPendingChange});
selectFiles(container, [makeFile()]);
// After file selection triggers upload, onPendingChange(true) should fire
expect(onPendingChange).toHaveBeenCalledWith(true);
const {onSuccess} = getUploadCallbacks();
const fileInfo = makeFileInfo();
act(() => {
onSuccess({file_infos: [fileInfo]});
});
expect(onPendingChange).toHaveBeenCalledWith(false);
});
it('onFileSelected NOT called on mount (hasInteractedRef guard)', () => {
const onFileSelected = jest.fn();
renderComponent({onFileSelected});
// Should not be called since user hasn't interacted
expect(onFileSelected).not.toHaveBeenCalled();
});
it('value prop hydrates files via Client4.getFileInfo on mount', async () => {
const hydratedFileInfo = makeFileInfo({id: 'pre-existing-file', name: 'hydrated.png'});
mockGetFileInfo.mockResolvedValue(hydratedFileInfo);
renderComponent({value: ['pre-existing-file']});
await waitFor(() => {
expect(screen.getByTestId('file-preview')).toBeVisible();
});
expect(mockGetFileInfo).toHaveBeenCalledWith('pre-existing-file');
expect(screen.getByTestId('file-preview-item-pre-existing-file')).toBeVisible();
});
it('deleted/inaccessible file IDs silently skipped during hydration', async () => {
const goodFileInfo = makeFileInfo({id: 'good-file', name: 'good.png'});
mockGetFileInfo.mockImplementation((fileId: string) => {
if (fileId === 'good-file') {
return Promise.resolve(goodFileInfo);
}
const err = new Error('Not found');
(err as any).status_code = 404;
return Promise.reject(err);
});
renderComponent({value: ['bad-file', 'good-file']});
await waitFor(() => {
expect(screen.getByTestId('file-preview-item-good-file')).toBeVisible();
});
// bad-file should not appear anywhere
expect(screen.queryByText('bad-file')).not.toBeInTheDocument();
});
it('does not duplicate a file when its ID is echoed back via value prop after upload', async () => {
// Scenario: user uploads a file → onFileSelected(['uploaded-id']) → parent sets
// value=['uploaded-id'] → hydration effect should not fetch/prepend a second entry.
const fileInfo = makeFileInfo({id: 'uploaded-id', name: 'uploaded.png'});
mockGetFileInfo.mockResolvedValue(fileInfo);
const onFileSelected = jest.fn();
const {container, rerender} = renderComponent({onFileSelected});
// Upload a file
selectFiles(container, [makeFile('uploaded.png')]);
const {onSuccess} = getUploadCallbacks();
act(() => {
onSuccess({file_infos: [fileInfo]});
});
// Verify it appears once
expect(screen.getAllByTestId('file-preview-item-uploaded-id')).toHaveLength(1);
// Parent echoes the ID back via value prop (as would happen after onFileSelected fires)
rerender(
<AppsFormFileUpload
{...baseProps}
onFileSelected={onFileSelected}
value={['uploaded-id']}
/>,
);
// Wait a tick for any async hydration to run
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
// Still only one entry — no duplicate hydration
expect(screen.getAllByTestId('file-preview-item-uploaded-id')).toHaveLength(1);
// getFileInfo should NOT have been called (ID is already in state)
expect(mockGetFileInfo).not.toHaveBeenCalled();
});
it('remove file after upload removes it from list', () => {
const onFileSelected = jest.fn();
const {container} = renderComponent({onFileSelected});
selectFiles(container, [makeFile('removable.png')]);
const {onSuccess} = getUploadCallbacks();
const fileInfo = makeFileInfo({id: 'removable-id', name: 'removable.png'});
act(() => {
onSuccess({file_infos: [fileInfo]});
});
expect(screen.getByTestId('file-preview-item-removable-id')).toBeVisible();
// Click the remove button
fireEvent.click(screen.getByTestId('remove-file-removable-id'));
expect(screen.queryByTestId('file-preview-item-removable-id')).not.toBeInTheDocument();
expect(onFileSelected).toHaveBeenLastCalledWith([]);
});
it('allowMultiple button shows "Choose Files" text', () => {
renderComponent({allowMultiple: true});
expect(screen.getByRole('button', {name: /choose files/i})).toBeVisible();
});
it('input accepts multiple files when allowMultiple is true', () => {
const {container} = renderComponent({allowMultiple: true});
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toHaveAttribute('multiple');
});
it('input does not accept multiple when allowMultiple is false', () => {
const {container} = renderComponent({allowMultiple: false});
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).not.toHaveAttribute('multiple');
});
it('placeholder is shown when no files are present', () => {
renderComponent({placeholder: 'Drag or select a file'});
expect(screen.getByText('Drag or select a file')).toBeVisible();
});
it('placeholder is hidden when files are present', () => {
const {container} = renderComponent({placeholder: 'Drag or select a file'});
selectFiles(container, [makeFile()]);
expect(screen.queryByText('Drag or select a file')).not.toBeInTheDocument();
});
it('helpText is rendered when provided', () => {
renderComponent({helpText: 'Max file size: 10MB'});
expect(screen.getByText('Max file size: 10MB')).toBeVisible();
});
it('submit button is disabled while upload is in progress', () => {
const onPendingChange = jest.fn();
const {container} = renderComponent({onPendingChange});
// Select a file to trigger upload
selectFiles(container, [makeFile('test.png')]);
// At this point upload is in progress, so onPendingChange(true) should have been called
expect(onPendingChange).toHaveBeenCalledWith(true);
// Verify the button is disabled by checking the Choose File button
const chooseButton = screen.getByRole('button', {name: /Choose File/i});
expect(chooseButton).toBeDisabled();
});
it('submit button is re-enabled after upload completes', () => {
const onPendingChange = jest.fn();
const {container} = renderComponent({onPendingChange});
// Select and upload a file
selectFiles(container, [makeFile('test.png')]);
const {onSuccess} = getUploadCallbacks();
const fileInfo = makeFileInfo({id: 'completed-id', name: 'test.png'});
act(() => {
onSuccess({file_infos: [fileInfo]});
});
// After upload completes, onPendingChange(false) should be called
expect(onPendingChange).toHaveBeenCalledWith(false);
// Verify the button is re-enabled
const chooseButton = screen.getByRole('button', {name: /Choose File/i});
expect(chooseButton).not.toBeDisabled();
});
it('concurrent uploads in two different file fields do not clobber state', async () => {
// Render two file upload fields with different IDs
const onFileSelected1 = jest.fn();
const onFileSelected2 = jest.fn();
const onPendingChange1 = jest.fn();
const onPendingChange2 = jest.fn();
const {container: container1} = renderComponent({
id: 'file-field-1',
onFileSelected: onFileSelected1,
onPendingChange: onPendingChange1,
});
const {container: container2} = renderComponent({
id: 'file-field-2',
onFileSelected: onFileSelected2,
onPendingChange: onPendingChange2,
});
// Start upload in field 1
selectFiles(container1, [makeFile('file1.png')]);
expect(onPendingChange1).toHaveBeenCalledWith(true);
// Start upload in field 2 (while field 1 is still uploading)
selectFiles(container2, [makeFile('file2.png')]);
expect(onPendingChange2).toHaveBeenCalledWith(true);
// Both should be uploading
expect(screen.getAllByTestId(/file-progress-/)).toHaveLength(2);
// Complete upload in field 1
const {onSuccess: onSuccess1} = getUploadCallbacks(0);
const fileInfo1 = makeFileInfo({id: 'file1-id', name: 'file1.png'});
act(() => {
onSuccess1({file_infos: [fileInfo1]});
});
// Field 1 should be done uploading
expect(onPendingChange1).toHaveBeenLastCalledWith(false);
// Field 2 should still be uploading
expect(onPendingChange2).toHaveBeenLastCalledWith(true);
expect(mockUploadFile).toHaveBeenCalledTimes(2);
// Complete upload in field 2
const {onSuccess: onSuccess2} = getUploadCallbacks(1);
const fileInfo2 = makeFileInfo({id: 'file2-id', name: 'file2.png'});
act(() => {
onSuccess2({file_infos: [fileInfo2]});
});
// Field 2 should now be done uploading
expect(onPendingChange2).toHaveBeenLastCalledWith(false);
// Verify both callbacks were called with the correct file IDs
expect(onFileSelected1).toHaveBeenCalledWith(['file1-id']);
expect(onFileSelected2).toHaveBeenCalledWith(['file2-id']);
});
it('upload state is tracked independently per field when allow_multiple is used', () => {
const onFileSelected = jest.fn();
const onPendingChange = jest.fn();
const {container} = renderComponent({
allowMultiple: true,
onFileSelected,
onPendingChange,
});
// Upload first file
selectFiles(container, [makeFile('file1.png')]);
expect(onPendingChange).toHaveBeenCalledWith(true);
// File should show uploading
const uploadingElements = screen.queryAllByTestId(/file-progress-/);
expect(uploadingElements).toHaveLength(1);
// Complete first upload
const {onSuccess: onSuccess1} = getUploadCallbacks();
act(() => {
onSuccess1({file_infos: [makeFileInfo({id: 'file1-id', name: 'file1.png'})]});
});
// After first file completes, show in FilePreview
expect(screen.getByTestId('file-preview-item-file1-id')).toBeInTheDocument();
// onFileSelected should be called with the completed file
expect(onFileSelected).toHaveBeenCalledWith(['file1-id']);
// onPendingChange should be false since no more uploads are in progress
expect(onPendingChange).toHaveBeenLastCalledWith(false);
});
});
});
@@ -0,0 +1,493 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {Button} from '@mattermost/shared/components/button';
import type {ServerError} from '@mattermost/types/errors';
import type {FileInfo} from '@mattermost/types/files';
import {MaxDialogFileIds} from '@mattermost/types/integrations';
import {logError} from 'mattermost-redux/actions/errors';
import {Client4} from 'mattermost-redux/client';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {uploadFile} from 'actions/file_actions';
import FilePreview from 'components/file_preview';
import type {FilePreviewInfo} from 'components/file_preview/file_preview';
import FileProgressPreview from 'components/file_preview/file_progress_preview';
import * as Utils from 'utils/utils';
import './apps_form_file_upload.scss';
interface FileState {
name: string;
stableId: string;
clientId: string;
status: 'selected' | 'uploading' | 'uploaded' | 'failed' | 'hydrated';
fileId?: string;
fileInfo?: FileInfo;
percent?: number;
error?: string;
}
export type Props = {
id: string;
label: React.ReactNode;
helpText?: React.ReactNode;
placeholder?: string;
onFileSelected: (fileIds: string[]) => void;
onPendingChange?: (hasPending: boolean) => void;
disabled?: boolean;
fileType?: string;
value?: string[]; // Array of uploaded file IDs
allowMultiple?: boolean; // Allow multiple file selection (default: false)
};
const AppsFormFileUpload: React.FC<Props> = ({
id,
label,
helpText,
placeholder,
onFileSelected,
onPendingChange,
disabled = false,
fileType = '*',
value,
allowMultiple = false,
}) => {
const dispatch = useDispatch();
const {formatMessage} = useIntl();
const fileInputRef = useRef<HTMLInputElement>(null);
const currentChannelId = useSelector(getCurrentChannelId);
const isMountedRef = useRef(true);
const onFileSelectedRef = useRef(onFileSelected);
onFileSelectedRef.current = onFileSelected;
const fileObjectsRef = useRef<Map<string, File>>(new Map());
const uploadRequestsRef = useRef<Map<string, XMLHttpRequest>>(new Map());
const hydratedRef = useRef<Set<string>>(new Set());
const hasInteractedRef = useRef(false);
const [files, setFiles] = useState<FileState[]>([]);
const filesRef = useRef<FileState[]>([]);
filesRef.current = files;
const [serverError, setServerError] = useState<string | undefined>(undefined);
const maxFiles = allowMultiple ? MaxDialogFileIds : 1;
// Derived from files state — avoids the stale-closure bug where setIsUploading(false)
// was being called synchronously after dispatching async uploads.
const isUploading = files.some((f) => f.status === 'uploading');
useEffect(() => {
return () => {
isMountedRef.current = false;
for (const xhr of uploadRequestsRef.current.values()) {
xhr?.abort();
}
uploadRequestsRef.current.clear();
fileObjectsRef.current.clear();
};
}, []);
// Reconcile files with value prop and hydrate new IDs
useEffect(() => {
let cancelled = false;
const valueSet = new Set(value ?? []);
// Remove files whose IDs are no longer in value (parent cleared/replaced them)
setFiles((prev) => {
const filtered = prev.filter((f) => {
if (f.status === 'hydrated' || f.status === 'uploaded') {
return f.fileId ? valueSet.has(f.fileId) : true;
}
return true;
});
if (filtered.length !== prev.length) {
for (const f of prev) {
if (f.fileId && !valueSet.has(f.fileId)) {
hydratedRef.current.delete(f.fileId);
}
}
}
if (filtered.length === prev.length) {
return prev;
}
return filtered;
});
// Hydrate new IDs not already present in state
const existingFileIds = new Set(
filesRef.current.map((f) => f.fileId).filter((fid): fid is string => Boolean(fid)),
);
// Don't re-hydrate IDs that were removed by user interaction (e.g., single-file
// replacement): the parent's value prop may still reference a just-replaced ID
// before the new upload settles, and apps_form_field passes a fresh value array
// each render, so this effect re-runs frequently. Once the user has interacted,
// the files state — not the incoming value — is the source of truth.
const newIds = hasInteractedRef.current ? [] : (value ?? []).filter((fid) => !hydratedRef.current.has(fid) && !existingFileIds.has(fid));
if (newIds.length > 0) {
const hydrate = async () => {
const hydratedFiles: FileState[] = [];
for (const fileId of newIds) {
try {
const fileInfo = await Client4.getFileInfo(fileId); // eslint-disable-line no-await-in-loop
if (cancelled) {
return;
}
hydratedFiles.push({
name: fileInfo.name,
stableId: Utils.generateId(),
clientId: '',
status: 'hydrated',
fileId,
fileInfo,
});
if (!allowMultiple && hydratedFiles.length >= maxFiles) {
break;
}
} catch (err: unknown) {
// Only skip 403/404 (deleted/inaccessible); rethrow everything else
const statusCode = (err && typeof err === 'object' && 'status_code' in err) ?
(err as {status_code: number}).status_code :
undefined;
if (statusCode !== 403 && statusCode !== 404) {
throw err;
}
}
}
if (cancelled) {
return;
}
for (const f of hydratedFiles) {
hydratedRef.current.add(f.fileId!);
}
if (hydratedFiles.length > 0) {
setFiles((prev) => (allowMultiple ? [...hydratedFiles, ...prev] : hydratedFiles));
}
// If some IDs were dropped (deleted/inaccessible), notify parent
// with the sanitized list so it stays in sync
if (hydratedFiles.length < newIds.length) {
const hydratedIds = new Set(hydratedFiles.map((f) => f.fileId!));
const existingIds = new Set(
filesRef.current.
filter((f) =>
(f.status === 'uploaded' || f.status === 'hydrated') &&
f.fileId &&
valueSet.has(f.fileId),
).
map((f) => f.fileId!),
);
const survivingIds = (value ?? []).filter((fileId) =>
hydratedIds.has(fileId) || existingIds.has(fileId),
);
onFileSelectedRef.current(allowMultiple ? survivingIds : survivingIds.slice(0, maxFiles));
}
};
hydrate().catch((err) => {
if (!cancelled) {
const serverErr = typeof err === 'string' ? {message: err} as ServerError : err;
dispatch(logError(serverErr));
setServerError(
serverErr.message ??
formatMessage({id: 'apps_form_file_upload.hydration_failed', defaultMessage: 'Failed to load file'}),
);
}
});
}
return () => {
cancelled = true;
};
}, [value, dispatch, formatMessage, allowMultiple, maxFiles]);
// Notify parent dialog when files are uploading so it can block submit
useEffect(() => {
onPendingChange?.(isUploading);
}, [isUploading, onPendingChange]);
const handleChooseClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const startUpload = useCallback((file: File, stableId: string) => {
const clientId = Utils.generateId();
setFiles((prevFiles) => prevFiles.map((f) =>
(f.stableId === stableId ? {...f, status: 'uploading', clientId} : f),
));
const xhr = dispatch(uploadFile({
file,
name: file.name,
type: file.type,
rootId: '',
channelId: currentChannelId || '',
clientId,
onProgress: (filePreviewInfo: FilePreviewInfo) => {
if (!isMountedRef.current) {
return;
}
const percent = filePreviewInfo.percent ?? 0;
setFiles((prev) => prev.map((f) =>
(f.stableId === stableId ? {...f, percent} : f),
));
},
onSuccess: (response: {file_infos: FileInfo[]}) => {
if (!isMountedRef.current) {
return;
}
const fileInfo = response.file_infos?.[0];
if (!fileInfo) {
return;
}
fileObjectsRef.current.delete(stableId);
uploadRequestsRef.current.delete(stableId);
setFiles((prevFiles) => prevFiles.map((f) =>
(f.stableId === stableId ? {...f, status: 'uploaded', fileId: fileInfo.id, fileInfo} : f),
));
},
onError: (err: string | ServerError) => {
if (!isMountedRef.current) {
return;
}
fileObjectsRef.current.delete(stableId);
uploadRequestsRef.current.delete(stableId);
dispatch(logError(typeof err === 'string' ? {message: err} as ServerError : err));
const errorMessage = typeof err === 'string' ? err : (err?.message ?? formatMessage({id: 'apps_form_file_upload.upload_failed', defaultMessage: 'Upload failed'}));
setFiles((prevFiles) => prevFiles.map((f) =>
(f.stableId === stableId ? {...f, status: 'failed', error: errorMessage} : f),
));
},
}));
uploadRequestsRef.current.set(stableId, xhr);
}, [currentChannelId, dispatch, formatMessage]);
const abortInFlightUploads = useCallback((fileStates: FileState[]) => {
for (const f of fileStates) {
if (f.status === 'uploading') {
const xhr = uploadRequestsRef.current.get(f.stableId);
if (xhr) {
xhr.abort();
uploadRequestsRef.current.delete(f.stableId);
}
}
fileObjectsRef.current.delete(f.stableId);
}
}, []);
const handleFileInput = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
hasInteractedRef.current = true;
const selectedFiles = event.target.files;
if (selectedFiles && selectedFiles.length > 0) {
let filesToProcess = Array.from(selectedFiles);
let uploadLimitError: string | undefined;
if (allowMultiple) {
const remaining = maxFiles - filesRef.current.length;
if (remaining <= 0) {
setServerError(
formatMessage({
id: 'apps_form_file_upload.max_files',
defaultMessage: 'Uploads limited to {count, number} files maximum.',
}, {count: maxFiles}),
);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
return;
}
if (filesToProcess.length > remaining) {
uploadLimitError = formatMessage({
id: 'apps_form_file_upload.max_files',
defaultMessage: 'Uploads limited to {count, number} files maximum.',
}, {count: maxFiles});
filesToProcess = filesToProcess.slice(0, remaining);
}
} else {
if (filesToProcess.length > 1) {
uploadLimitError = formatMessage({
id: 'apps_form_file_upload.max_files',
defaultMessage: 'Uploads limited to {count, number} files maximum.',
}, {count: maxFiles});
}
filesToProcess = filesToProcess.slice(0, maxFiles);
abortInFlightUploads(filesRef.current);
}
const filesToUpload: Array<{file: File; stableId: string}> = [];
const newFiles = filesToProcess.map((file) => {
const stableId = Utils.generateId();
fileObjectsRef.current.set(stableId, file);
filesToUpload.push({file, stableId});
return {
name: file.name,
stableId,
clientId: '',
status: 'selected' as const,
};
});
setFiles(() => {
return allowMultiple ? [...filesRef.current, ...newFiles] : newFiles;
});
setServerError(uploadLimitError);
// Clear the input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
// Auto-upload immediately
for (const {file, stableId} of filesToUpload) {
startUpload(file, stableId);
}
}
}, [allowMultiple, maxFiles, startUpload, formatMessage, abortInFlightUploads]);
// Notify parent when uploads settle. Skips mount-time fire to avoid clobbering
// pre-populated values before hydration completes.
useEffect(() => {
if (files.some((f) => f.status === 'uploading') || !hasInteractedRef.current) {
return;
}
const completedFiles = files.filter((f) => (f.status === 'uploaded' || f.status === 'hydrated') && f.fileId);
const fileIds = completedFiles.map((f) => f.fileId!);
onFileSelectedRef.current(allowMultiple ? fileIds : fileIds.slice(0, maxFiles));
}, [files, allowMultiple, maxFiles]);
const handleRemoveById = useCallback((idToRemove: string) => {
hasInteractedRef.current = true;
// Abort any in-progress upload before updating state (side effects must stay outside updater)
for (const f of filesRef.current) {
if ((f.fileId === idToRemove || f.clientId === idToRemove) && f.status === 'uploading') {
const xhr = uploadRequestsRef.current.get(f.stableId);
if (xhr) {
xhr.abort();
uploadRequestsRef.current.delete(f.stableId);
}
}
}
setFiles((prevFiles) =>
prevFiles.filter((f) => f.fileId !== idToRemove && f.clientId !== idToRemove),
);
setServerError(undefined);
}, []);
const uploadingFiles = files.filter((f) => f.status === 'uploading');
const completedFiles = files.filter((f) => (f.status === 'uploaded' || f.status === 'hydrated') && f.fileInfo);
const failedFiles = files.filter((f) => f.status === 'failed');
const atFileLimit = allowMultiple && files.length >= maxFiles;
return (
<div className='form-group apps-form-file-upload'>
<label
htmlFor={id}
className='control-label'
>
{label}
</label>
<div>
{/* Uploading files — progress bars */}
{uploadingFiles.length > 0 && (
<div className='file-preview__container'>
{uploadingFiles.map((f) => (
<FileProgressPreview
key={f.stableId}
clientId={f.clientId}
fileInfo={{clientId: f.clientId, name: f.name, percent: f.percent ?? 0, type: ''} as FilePreviewInfo}
handleRemove={handleRemoveById}
/>
))}
</div>
)}
{/* Completed files — standard file preview with remove button */}
{completedFiles.length > 0 && (
<FilePreview
fileInfos={completedFiles.map((f) => f.fileInfo! as FilePreviewInfo)}
onRemove={handleRemoveById}
/>
)}
{/* Failed files — error messages */}
{failedFiles.length > 0 && (
<div className='apps-form-file-upload__errors'>
{failedFiles.map((f) => (
<div
key={f.stableId}
className='apps-form-file-upload__error-message'
>
<i className='icon icon-close apps-form-file-upload__error-icon'/>
<span>{f.name}{': '}{f.error}</span>
</div>
))}
</div>
)}
{/* Choose file button */}
<div className='apps-form-file-upload__buttons'>
<div className='file__upload'>
<Button
type='button'
emphasis='tertiary'
disabled={disabled || isUploading || atFileLimit}
onClick={handleChooseClick}
>
<FormattedMessage
id={allowMultiple ? 'admin.file_upload.chooseFiles' : 'admin.file_upload.chooseFile'}
defaultMessage={allowMultiple ? 'Choose Files' : 'Choose File'}
/>
</Button>
<input
ref={fileInputRef}
id={id}
type='file'
accept={fileType}
onChange={handleFileInput}
disabled={disabled || isUploading || atFileLimit}
multiple={allowMultiple}
/>
</div>
</div>
{serverError && (
<div className='form-group has-error'>
<label className='control-label'>{serverError}</label>
</div>
)}
{helpText && (
<div className='help-text'>
{helpText}
</div>
)}
{placeholder && files.length === 0 && (
<div className='help-text'>
{placeholder}
</div>
)}
</div>
</div>
);
};
export default AppsFormFileUpload;
@@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import AppsFormFileUpload from './apps_form_file_upload';
export default AppsFormFileUpload;
@@ -270,6 +270,17 @@ class InteractiveDialogAdapter extends React.PureComponent<Props> {
}
}
// Collect file IDs from file-type elements for server-side validation
const fileIds: string[] = [];
if (this.currentDialogElements) {
this.currentDialogElements.forEach((elem) => {
if (elem.type === 'file' && finalSubmission[elem.name]) {
const ids = String(finalSubmission[elem.name]).split(',').filter(Boolean);
fileIds.push(...ids);
}
});
}
const legacySubmission: DialogSubmission = {
url: this.props.url || '',
callback_id: this.props.callbackId || '',
@@ -279,6 +290,7 @@ class InteractiveDialogAdapter extends React.PureComponent<Props> {
channel_id: '', // Populated by submitInteractiveDialog action
team_id: '', // Populated by submitInteractiveDialog action
cancelled: false,
...(fileIds.length > 0 && {file_ids: fileIds}),
};
const result = await this.props.actions.submitInteractiveDialog(legacySubmission);
+4
View File
@@ -3929,6 +3929,9 @@
"app.post.move_thread_command.direct_or_group.multiple_messages": "A thread with {numMessages, number} messages has been moved to a Direct/Group Message\n",
"app.post.move_thread_command.direct_or_group.one_message": "A message has been moved to a Direct/Group Message\n",
"app.post.move_thread.from_another_channel": "This thread was moved from another channel",
"apps_form_file_upload.hydration_failed": "Failed to load file",
"apps_form_file_upload.max_files": "Uploads limited to {count, number} files maximum.",
"apps_form_file_upload.upload_failed": "Upload failed",
"apps_form.date_field.placeholder": "Select a date",
"apps_form.datetime_field.timezone_hint": "Times in {timezone}",
"apps.error": "Error: {error}",
@@ -5548,6 +5551,7 @@
"interactive_dialog.error.bad_number": "Must be a number.",
"interactive_dialog.error.bad_url": "URL must include http:// or https://.",
"interactive_dialog.error.before_min_date": "Selected time is before the minimum allowed date.",
"interactive_dialog.error.invalid_file": "Invalid file upload.",
"interactive_dialog.error.invalid_option": "Must be a valid option",
"interactive_dialog.error.required": "This field is required.",
"interactive_dialog.error.too_short": "Minimum input length is {minLength}.",
@@ -42,5 +42,6 @@ export const AppFieldTypes: {[name: string]: AppFieldType} = {
RADIO: 'radio',
DATE: 'date',
DATETIME: 'datetime',
FILE: 'file',
ACTION_BUTTON: 'action_button',
};
@@ -187,6 +187,19 @@ export function checkDialogElementForError(elem: DialogElement, value: any): Dia
}
}
return null;
} else if (type === 'file') {
// File elements store file IDs, so we just need to check if file was uploaded
// The actual validation that file exists will be done server-side
if (Array.isArray(value) && value.length === 0) {
// An empty array means no files selected — treat as no value, not invalid.
return null;
}
if (value && typeof value !== 'string') {
return defineMessage({
id: 'interactive_dialog.error.invalid_file',
defaultMessage: 'Invalid file upload.',
});
}
}
return null;
@@ -166,6 +166,18 @@ describe('dialog_conversion', () => {
it('should return null for unknown types', () => {
expect(getFieldType({type: 'unknown'} as DialogElement)).toBeNull();
});
it('should map file fields to FILE type', () => {
expect(getFieldType({type: DialogElementTypes.FILE} as DialogElement)).toBe('file');
});
it('should map date fields correctly', () => {
expect(getFieldType({type: DialogElementTypes.DATE} as DialogElement)).toBe('date');
});
it('should map datetime fields correctly', () => {
expect(getFieldType({type: DialogElementTypes.DATETIME} as DialogElement)).toBe('datetime');
});
});
describe('getDefaultValue', () => {
@@ -1074,6 +1086,151 @@ describe('dialog_conversion', () => {
expect(form.submit?.state).toBeUndefined();
expect(form.source?.state).toBeUndefined();
});
it('should convert file element to FILE field type', () => {
const elements: DialogElement[] = [
{
name: 'file_field',
type: 'file',
display_name: 'Upload File',
optional: false,
} as DialogElement,
];
const {form, errors} = convertDialogToAppForm(
elements,
'Test Dialog',
undefined,
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
expect(errors).toHaveLength(0);
expect(form.fields).toHaveLength(1);
expect(form.fields?.[0].type).toBe('file');
expect(form.fields?.[0].name).toBe('file_field');
expect(form.fields?.[0].label).toBe('Upload File');
});
it('should preserve allow_multiple property on file fields', () => {
const elements: DialogElement[] = [
{
name: 'file_field',
type: 'file',
display_name: 'Upload Files',
optional: false,
allow_multiple: true,
} as DialogElement,
];
const {form, errors} = convertDialogToAppForm(
elements,
'Test Dialog',
undefined,
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
expect(errors).toHaveLength(0);
expect(form.fields?.[0].allow_multiple).toBe(true);
});
it('should not set allow_multiple if not present on file fields', () => {
const elements: DialogElement[] = [
{
name: 'file_field',
type: 'file',
display_name: 'Upload File',
optional: false,
} as DialogElement,
];
const {form, errors} = convertDialogToAppForm(
elements,
'Test Dialog',
undefined,
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
expect(errors).toHaveLength(0);
expect(form.fields?.[0].allow_multiple).toBeUndefined();
});
it('should handle file elements with placeholder and help text', () => {
const elements: DialogElement[] = [
{
name: 'file_field',
type: 'file',
display_name: 'Upload File',
placeholder: 'Choose a file to upload',
help_text: 'Supported formats: PDF, PNG, JPG',
optional: false,
} as DialogElement,
];
const {form, errors} = convertDialogToAppForm(
elements,
'Test Dialog',
undefined,
undefined,
undefined,
'http://example.com',
'',
legacyOptions,
);
expect(errors).toHaveLength(0);
expect(form.fields?.[0].hint).toBe('Choose a file to upload');
expect(form.fields?.[0].description).toBe('Supported formats: PDF, PNG, JPG');
});
it('should handle multiple file upload fields with different settings', () => {
const elements: DialogElement[] = [
{
name: 'single_file',
type: 'file',
display_name: 'Single File',
optional: false,
} as DialogElement,
{
name: 'multi_files',
type: 'file',
display_name: 'Multiple Files',
optional: true,
allow_multiple: true,
} 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);
expect(form.fields?.[0].name).toBe('single_file');
expect(form.fields?.[0].allow_multiple).toBeUndefined();
expect(form.fields?.[0].is_required).toBe(true);
expect(form.fields?.[1].name).toBe('multi_files');
expect(form.fields?.[1].allow_multiple).toBe(true);
expect(form.fields?.[1].is_required).toBe(false);
});
});
describe('convertAppFormValuesToDialogSubmission', () => {
+13 -1
View File
@@ -17,6 +17,7 @@ export const DialogElementTypes = {
RADIO: 'radio',
DATE: 'date',
DATETIME: 'datetime',
FILE: 'file',
ACTION_BUTTON: 'action_button',
} as const;
@@ -232,6 +233,8 @@ export function getFieldType(element: DialogElement): string | null {
return AppFieldTypes.DATE;
case DialogElementTypes.DATETIME:
return AppFieldTypes.DATETIME;
case DialogElementTypes.FILE:
return AppFieldTypes.FILE;
case DialogElementTypes.ACTION_BUTTON:
return AppFieldTypes.ACTION_BUTTON;
default:
@@ -421,6 +424,11 @@ export function convertElement(element: DialogElement, options: ConversionOption
appField.subtype = element.subtype;
}
// Add allow_multiple support for file fields
if (element.type === DialogElementTypes.FILE && element.allow_multiple) {
appField.allow_multiple = true;
}
// Add length constraints for text fields
if (element.type === DialogElementTypes.TEXT || element.type === DialogElementTypes.TEXTAREA) {
if (element.min_length !== undefined) {
@@ -812,12 +820,16 @@ export function convertAppFormValuesToDialogSubmission(
submission[element.name] = value;
}
break;
case DialogElementTypes.DATE:
case DialogElementTypes.DATETIME:
// Date and datetime values should be passed through as strings (ISO format)
submission[element.name] = String(value);
break;
case DialogElementTypes.FILE:
// File elements store file IDs as strings
submission[element.name] = String(value || '');
break;
case DialogElementTypes.ACTION_BUTTON:
break;
default:
+7
View File
@@ -2638,6 +2638,13 @@ export default class Client4 {
);
};
getFileInfo = (fileId: string) => {
return this.doFetch<FileInfo>(
`${this.getFileRoute(fileId)}/info`,
{method: 'get'},
);
};
getFlaggedPosts = (userId: string, channelId = '', teamId = '', page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch<PostList>(
`${this.getUserRoute(userId)}/posts/flagged${buildQueryString({channel_id: channelId, team_id: teamId, page, per_page: perPage})}`,
+7
View File
@@ -476,6 +476,9 @@ export type AppField = {
multiselect?: boolean;
lookup?: AppCall;
// File props
allow_multiple?: boolean;
// Text props
subtype?: string;
min_length?: number;
@@ -575,6 +578,10 @@ function isAppField(v: unknown): v is AppField {
return false;
}
if (field.allow_multiple !== undefined && typeof field.allow_multiple !== 'boolean') {
return false;
}
if (field.lookup !== undefined && !isAppCall(field.lookup)) {
return false;
}
@@ -176,6 +176,7 @@ export type DialogSubmission = {
};
cancelled: boolean;
type?: string;
file_ids?: string[];
};
export type DialogElement = {
@@ -192,6 +193,7 @@ export type DialogElement = {
data_source: string;
data_source_url?: string;
multiselect?: boolean;
allow_multiple?: boolean;
options: Array<{
text: string;
value: any;
@@ -232,3 +234,6 @@ export type SubmitDialogResponse = {
type?: string;
form?: Dialog;
};
// Keep in sync with server/public/model/integration_action.go MaxDialogFileIds.
export const MaxDialogFileIds = 10;