mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-01 15:00:08 +08:00
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:
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+44
@@ -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;
|
||||
}
|
||||
}
|
||||
+735
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+493
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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})}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user