Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions src/client/javascripts/file-upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -304,16 +304,19 @@ function pollUploadStatus(uploadId) {
* @param {HTMLInputElement} fileInput - The file input element
* @param {HTMLButtonElement} uploadButton - The upload button
* @param {HTMLButtonElement} continueButton - The continue button
* @param {File | null} selectedFile - The selected file
* @param {File[]} selectedFiles - The selected files
*/
function handleStandardFormSubmission(
formElement,
fileInput,
uploadButton,
continueButton,
selectedFile
selectedFiles
) {
renderSummary(selectedFile, 'Uploading…', formElement)
// Render in reverse so first file ends up at the top of the summary list
for (let i = selectedFiles.length - 1; i >= 0; i--) {
renderSummary(selectedFiles[i], 'Uploading…', formElement)
}

fileInput.focus()

Expand Down Expand Up @@ -403,8 +406,8 @@ function initUpload() {
}

const formElement = /** @type {HTMLFormElement} */ (form)
/** @type {File | null} */
let selectedFile = null
/** @type {File[]} */
let selectedFiles = []
let isSubmitting = false
const uploadId = formElement.dataset.uploadId

Expand All @@ -414,12 +417,12 @@ function initUpload() {
}

if (fileInput.files && fileInput.files.length > 0) {
selectedFile = fileInput.files[0]
selectedFiles = Array.from(fileInput.files)
}
})

uploadButton.addEventListener('click', (event) => {
if (!selectedFile) {
if (selectedFiles.length === 0) {
event.preventDefault()
showError(
'Select a file',
Expand All @@ -436,12 +439,13 @@ function initUpload() {

isSubmitting = true

// Show all selected files in the summary table
handleStandardFormSubmission(
formElement,
fileInput,
uploadButton,
continueButton,
selectedFile
selectedFiles
)

handleAjaxFormSubmission(
Expand Down
19 changes: 11 additions & 8 deletions src/server/plugins/engine/components/FileUploadField.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ describe('FileUploadField', () => {
actions: {
items: [
{
href: `/test/file-upload-component/${validState[0].uploadId}/confirm-delete`,
href: `/test/file-upload-component/${validState[0].status.form.file.fileId}/confirm-delete`,
text: 'Remove',
attributes: { id: 'myComponent__0' },
classes: 'govuk-link--no-visited-state',
Expand All @@ -424,7 +424,7 @@ describe('FileUploadField', () => {
actions: {
items: [
{
href: `/test/file-upload-component/${validState[1].uploadId}/confirm-delete`,
href: `/test/file-upload-component/${validState[1].status.form.file.fileId}/confirm-delete`,
text: 'Remove',
attributes: { id: 'myComponent__1' },
classes: 'govuk-link--no-visited-state',
Expand All @@ -443,7 +443,7 @@ describe('FileUploadField', () => {
actions: {
items: [
{
href: `/test/file-upload-component/${validState[2].uploadId}/confirm-delete`,
href: `/test/file-upload-component/${validState[2].status.form.file.fileId}/confirm-delete`,
text: 'Remove',
attributes: { id: 'myComponent__2' },
classes: 'govuk-link--no-visited-state',
Expand All @@ -454,7 +454,8 @@ describe('FileUploadField', () => {
}
]
}
}
},
multiple: true
})
)
})
Expand Down Expand Up @@ -543,7 +544,7 @@ describe('FileUploadField', () => {
actions: {
items: [
{
href: `/test/file-upload-component/${validState[2].uploadId}/confirm-delete`,
href: `/test/file-upload-component/${validState[2].status.form.file.fileId}/confirm-delete`,
text: 'Remove',
attributes: { id: 'myComponent__0' },
classes: 'govuk-link--no-visited-state',
Expand All @@ -554,7 +555,8 @@ describe('FileUploadField', () => {
}
]
}
}
},
multiple: true
})
)
})
Expand Down Expand Up @@ -583,7 +585,7 @@ describe('FileUploadField', () => {
actions: {
items: [
{
href: `/test/file-upload-component/${validState[2].uploadId}/confirm-delete`,
href: `/test/file-upload-component/${validState[2].status.form.file.fileId}/confirm-delete`,
text: 'Remove',
attributes: { id: 'myComponent__0' },
classes: 'govuk-link--no-visited-state',
Expand All @@ -594,7 +596,8 @@ describe('FileUploadField', () => {
}
]
}
}
},
multiple: true
})
)
})
Expand Down
19 changes: 14 additions & 5 deletions src/server/plugins/engine/components/FileUploadField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,12 @@ export const tempStatusSchema = joi
.valid(UploadStatus.ready, UploadStatus.pending)
.required(),
metadata: metadataSchema,
form: joi.object().required().keys({
file: tempFileSchema
}),
form: joi
.object()
.required()
.keys({
file: joi.array().items(tempFileSchema).single().required()
}),
numberOfRejectedFiles: joi.number().optional()
})
.required()
Expand Down Expand Up @@ -191,7 +194,7 @@ export class FileUploadField extends FormComponent {
errors?: FormSubmissionError[],
query: FormQuery = {}
) {
const { options, page } = this
const { options, page, schema } = this

// Allow preview URL direct access
const isForceAccess = 'force' in query
Expand Down Expand Up @@ -233,7 +236,7 @@ export class FileUploadField extends FormComponent {

// Remove summary list actions from previews
if (!isForceAccess) {
const path = `/${item.uploadId}/confirm-delete`
const path = `/${file.fileId}/confirm-delete`
const href = page?.getHref(`${page.path}${path}`) ?? '#'

items.push({
Expand Down Expand Up @@ -263,6 +266,9 @@ export class FileUploadField extends FormComponent {
attributes.accept = options.accept
}

// Allow multiple file selection when schema permits more than 1 file
const allowsMultiple = schema.max !== 1 && schema.length !== 1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice thinking


const summaryList: SummaryList = {
classes: 'govuk-summary-list--long-key',
rows
Expand All @@ -277,6 +283,9 @@ export class FileUploadField extends FormComponent {
// Override the component name we send to CDP
name: 'file',

// Enable multi-file selection in the file picker
...(allowsMultiple && { multiple: true }),

upload: {
count,
summaryList
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,83 @@ describe('FileUploadPageController', () => {
})
})

it('collects all file errors into a single flash when multiple files fail', async () => {
const state = {
upload: {
[controller.path]: {
upload: {
uploadId: 'some-id',
uploadUrl: 'some-url',
statusUrl: 'some-status-url'
},
files: []
}
}
} as unknown as FormSubmissionState

const errorStatus = {
uploadStatus: UploadStatus.ready,
form: {
file: [
{
fileStatus: FileStatus.rejected,
errorMessage: 'File too large'
},
{
fileStatus: FileStatus.rejected,
errorMessage: 'Invalid file type'
}
]
}
}

jest
.spyOn(uploadService, 'getUploadStatus')
.mockResolvedValue(errorStatus as unknown as UploadStatusResponse)

jest.spyOn(tempItemSchema, 'validate').mockReturnValue({
value: {
status: errorStatus,
uploadId: 'some-id'
},
error: undefined
} as ValidationResult)

const testController = controller as TestableFileUploadPageController

const initiateSpy = jest.spyOn(
testController,
'initiateAndStoreNewUpload'
) as jest.SpyInstance<
Promise<FormSubmissionState>,
[FormRequest, FormSubmissionState]
>

initiateSpy.mockResolvedValue(state)

const cacheService = getCacheService(request.server)

await controller['checkUploadStatus'](request, state, 1)

expect(cacheService.setFlash).toHaveBeenCalledTimes(1)
expect(cacheService.setFlash).toHaveBeenCalledWith(request, {
errors: [
{
path: ['fileUpload'],
href: '#fileUpload',
name: 'fileUpload',
text: 'File too large'
},
{
path: ['fileUpload'],
href: '#fileUpload',
name: 'fileUpload',
text: 'Invalid file type'
}
]
})
})

it('sets default error message when none provided', async () => {
const state = {
upload: {
Expand Down Expand Up @@ -859,7 +936,16 @@ describe('FileUploadPageController', () => {

describe('file removal', () => {
it('returns early when no file is removed', async () => {
const files = [{ uploadId: 'file1' }, { uploadId: 'file2' }]
const files = [
{
uploadId: 'upload1',
status: { form: { file: { fileId: 'file1' } } }
},
{
uploadId: 'upload2',
status: { form: { file: { fileId: 'file2' } } }
}
]

Object.defineProperty(request, 'params', {
value: { itemId: 'nonexistent-file' },
Expand Down Expand Up @@ -892,7 +978,16 @@ describe('FileUploadPageController', () => {
})

it('merges state when file is removed', async () => {
const files = [{ uploadId: 'file1' }, { uploadId: 'file2' }]
const files = [
{
uploadId: 'upload1',
status: { form: { file: { fileId: 'file1' } } }
},
{
uploadId: 'upload2',
status: { form: { file: { fileId: 'file2' } } }
}
]

Object.defineProperty(request, 'params', {
value: { itemId: 'file1' },
Expand Down Expand Up @@ -924,7 +1019,12 @@ describe('FileUploadPageController', () => {
expect(mergeStateSpy).toHaveBeenCalledWith(request, state, {
upload: {
[controller.path]: {
files: [{ uploadId: 'file2' }],
files: [
{
uploadId: 'upload2',
status: { form: { file: { fileId: 'file2' } } }
}
],
upload: {
uploadId: 'upload-123',
uploadUrl: 'some-url',
Expand Down Expand Up @@ -1121,11 +1221,15 @@ describe('FileUploadPageController', () => {
files: [
{
uploadId: 'file-1',
status: { form: { file: { filename: 'file-1.pdf' } } }
status: {
form: { file: { fileId: 'file-1', filename: 'file-1.pdf' } }
}
},
{
uploadId: 'file-2',
status: { form: { file: { filename: 'file-2.pdf' } } }
status: {
form: { file: { fileId: 'file-2', filename: 'file-2.pdf' } }
}
}
]
}
Expand Down
Loading
Loading