@defra/forms-engine-plugin 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.public/javascripts/application.min.js +1 -1
- package/.public/javascripts/application.min.js.map +1 -1
- package/.public/javascripts/shared.min.js +1 -1
- package/.public/javascripts/shared.min.js.map +1 -1
- package/.server/client/javascripts/file-upload.js +13 -8
- package/.server/client/javascripts/file-upload.js.map +1 -1
- package/.server/server/plugins/engine/components/FileUploadField.d.ts +3 -2
- package/.server/server/plugins/engine/components/FileUploadField.js +11 -3
- package/.server/server/plugins/engine/components/FileUploadField.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/FileUploadPageController.d.ts +11 -0
- package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js +65 -28
- package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js.map +1 -1
- package/package.json +1 -1
- package/src/client/javascripts/file-upload.js +12 -8
- package/src/server/plugins/engine/components/FileUploadField.test.ts +11 -8
- package/src/server/plugins/engine/components/FileUploadField.ts +14 -5
- package/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +109 -5
- package/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +69 -21
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FileUploadPageController.js","names":["ComponentType","Boom","wait","StatusCodes","FileUploadField","tempItemSchema","getCacheService","getError","getExponentialBackoffDelay","QuestionPageController","getProxyUrlForLocalDevelopment","getUploadStatus","initiateUpload","FileStatus","UploadStatus","MAX_UPLOADS","CDP_UPLOAD_TIMEOUT_MS","prepareStatus","status","file","form","isPending","fileStatus","pending","errorMessage","prepareFileState","fileState","FileUploadPageController","fileUpload","fileDeleteViewName","constructor","model","pageDef","collection","fileUploads","fields","filter","field","type","at","length","badImplementation","path","indexOf","name","viewName","getStateKeys","component","pagePath","page","getFormDataFromState","request","state","payload","files","getFilesFromState","undefined","getState","refreshUpload","uploadState","upload","getUploadFromState","makeGetItemDeleteRouteHandler","context","h","viewModel","params","fileToRemove","find","uploadId","itemId","notFound","filename","view","backLink","getBackLink","pageTitle","itemTitle","confirmation","text","buttonConfirm","buttonCancel","makePostItemDeleteRouteHandler","confirm","getFormParams","checkRemovedFiles","proceed","getErrors","details","errors","forEach","error","isUploadError","isUploadRootError","push","value","href","getViewModel","components","formComponent","id","index","proxyUrl","uploadUrl","formAction","componentsBefore","slice","checkUploadStatus","depth","initiateAndStoreNewUpload","statusResponse","err","isBoom","output","statusCode","NOT_FOUND","valueOf","badRequest","uploadStatus","initiated","Error","logger","gatewayTimeout","toFixed","delay","info","validationResult","validate","stripUnknown","complete","unshift","mergeState","cacheService","server","setFlash","filesUpdated","options","schema","getFormMetadata","services","formsService","max","Math","min","formMetadata","slug","notificationEmail","newUpload","accept"],"sources":["../../../../../src/server/plugins/engine/pageControllers/FileUploadPageController.ts"],"sourcesContent":["import { ComponentType, type PageFileUpload } from '@defra/forms-model'\nimport Boom from '@hapi/boom'\nimport { wait } from '@hapi/hoek'\nimport { StatusCodes } from 'http-status-codes'\nimport { type ValidationErrorItem } from 'joi'\n\nimport {\n FileUploadField,\n tempItemSchema\n} from '~/src/server/plugins/engine/components/FileUploadField.js'\nimport { type FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'\nimport {\n getCacheService,\n getError,\n getExponentialBackoffDelay\n} from '~/src/server/plugins/engine/helpers.js'\nimport { type FormModel } from '~/src/server/plugins/engine/models/index.js'\nimport { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js'\nimport { getProxyUrlForLocalDevelopment } from '~/src/server/plugins/engine/pageControllers/helpers/index.js'\nimport {\n getUploadStatus,\n initiateUpload\n} from '~/src/server/plugins/engine/services/uploadService.js'\nimport {\n FileStatus,\n UploadStatus,\n type AnyFormRequest,\n type FeaturedFormPageViewModel,\n type FileState,\n type FormContext,\n type FormContextRequest,\n type FormSubmissionError,\n type FormSubmissionState,\n type ItemDeletePageViewModel,\n type UploadInitiateResponse,\n type UploadStatusFileResponse\n} from '~/src/server/plugins/engine/types.js'\nimport {\n type FormRequest,\n type FormRequestPayload,\n type FormResponseToolkit\n} from '~/src/server/routes/types.js'\n\nconst MAX_UPLOADS = 25\nconst CDP_UPLOAD_TIMEOUT_MS = 60000 // 1 minute\n\nexport function prepareStatus(status: UploadStatusFileResponse) {\n const file = status.form.file\n const isPending = file.fileStatus === FileStatus.pending\n\n if (!file.errorMessage && isPending) {\n file.errorMessage = 'The selected file has not fully uploaded'\n }\n\n return status\n}\n\nfunction prepareFileState(fileState: FileState) {\n prepareStatus(fileState.status)\n\n return fileState\n}\n\nexport class FileUploadPageController extends QuestionPageController {\n declare pageDef: PageFileUpload\n\n fileUpload: FileUploadField\n fileDeleteViewName = 'item-delete'\n\n constructor(model: FormModel, pageDef: PageFileUpload) {\n super(model, pageDef)\n\n const { collection } = this\n\n // Get the file upload fields from the collection\n const fileUploads = collection.fields.filter(\n (field): field is FileUploadField =>\n field.type === ComponentType.FileUploadField\n )\n\n const fileUpload = fileUploads.at(0)\n\n // Assert we have exactly 1 file upload component\n if (!fileUpload || fileUploads.length > 1) {\n throw Boom.badImplementation(\n `Expected 1 FileUploadFieldComponent in FileUploadPageController '${pageDef.path}'`\n )\n }\n\n // Assert the file upload component is the first form component\n if (collection.fields.indexOf(fileUpload) !== 0) {\n throw Boom.badImplementation(\n `Expected '${fileUpload.name}' to be the first form component in FileUploadPageController '${pageDef.path}'`\n )\n }\n\n // Assign the file upload component to the controller\n this.fileUpload = fileUpload\n this.viewName = 'file-upload'\n }\n\n /**\n * Get supplementary state keys for clearing file upload state.\n * Returns the nested upload path for FileUploadField components only.\n * @param component - The component to get supplementary state keys for\n * @returns Array containing the nested upload path, e.g., [\"upload['/page-path']\"]\n * or ['upload'] if no page path is available. Returns empty array for non-FileUploadField components.\n */\n getStateKeys(component: FormComponent): string[] {\n // Only return upload keys for FileUploadField components\n if (!(component instanceof FileUploadField)) {\n return []\n }\n\n const pagePath = component.page?.path\n return pagePath ? [`upload['${pagePath}']`] : ['upload']\n }\n\n getFormDataFromState(\n request: FormContextRequest | undefined,\n state: FormSubmissionState\n ) {\n const { fileUpload } = this\n\n const payload = super.getFormDataFromState(request, state)\n const files = this.getFilesFromState(state)\n\n // Append the files to the payload\n payload[fileUpload.name] = files.length ? files : undefined\n\n return payload\n }\n\n async getState(request: AnyFormRequest) {\n const { fileUpload } = this\n\n // Get the actual state\n const state = await super.getState(request)\n const files = this.getFilesFromState(state)\n\n // Overwrite the files with those in the upload state\n state[fileUpload.name] = files\n\n return this.refreshUpload(request, state)\n }\n\n /**\n * Get the uploaded files from state.\n */\n getFilesFromState(state: FormSubmissionState) {\n const { path } = this\n\n const uploadState = state.upload?.[path]\n return uploadState?.files ?? []\n }\n\n /**\n * Get the initiated upload from state.\n */\n getUploadFromState(state: FormSubmissionState) {\n const { path } = this\n\n const uploadState = state.upload?.[path]\n return uploadState?.upload\n }\n\n makeGetItemDeleteRouteHandler() {\n return (\n request: FormRequest,\n context: FormContext,\n h: FormResponseToolkit\n ) => {\n const { viewModel } = this\n const { params } = request\n const { state } = context\n\n const files = this.getFilesFromState(state)\n\n const fileToRemove = files.find(\n ({ uploadId }) => uploadId === params.itemId\n )\n\n if (!fileToRemove) {\n throw Boom.notFound('File to delete not found')\n }\n\n const { filename } = fileToRemove.status.form.file\n\n return h.view(this.fileDeleteViewName, {\n ...viewModel,\n context,\n backLink: this.getBackLink(request, context),\n pageTitle: `Are you sure you want to remove this file?`,\n itemTitle: filename,\n confirmation: { text: 'You cannot recover removed files.' },\n buttonConfirm: { text: 'Remove file' },\n buttonCancel: { text: 'Cancel' }\n } satisfies ItemDeletePageViewModel)\n }\n }\n\n makePostItemDeleteRouteHandler() {\n return async (\n request: FormRequestPayload,\n context: FormContext,\n h: FormResponseToolkit\n ) => {\n const { path } = this\n const { state } = context\n\n const { confirm } = this.getFormParams(request)\n\n // Check for any removed files in the POST payload\n if (confirm) {\n await this.checkRemovedFiles(request, state)\n return this.proceed(request, h, path)\n }\n\n return this.proceed(request, h)\n }\n }\n\n getErrors(details?: ValidationErrorItem[]) {\n const { fileUpload } = this\n\n if (details) {\n const errors: FormSubmissionError[] = []\n\n details.forEach((error) => {\n const isUploadError = error.path[0] === fileUpload.name\n const isUploadRootError = isUploadError && error.path.length === 1\n\n if (!isUploadError || isUploadRootError) {\n // The error is for the root of the upload or another\n // field on the page so defer to the getError helper\n errors.push(getError(error))\n } else {\n const { context, path, type } = error\n\n if (type === 'object.unknown' && path.at(-1) === 'errorMessage') {\n const value = context?.value as string | undefined\n\n if (value) {\n const name = fileUpload.name\n const text = typeof value === 'string' ? value : 'Unknown error'\n const href = `#${name}`\n\n errors.push({ path, href, name, text })\n }\n }\n }\n })\n\n return errors\n }\n }\n\n getViewModel(\n request: FormContextRequest,\n context: FormContext\n ): FeaturedFormPageViewModel {\n const { fileUpload } = this\n const { state } = context\n\n const upload = this.getUploadFromState(state)\n\n const viewModel = super.getViewModel(request, context)\n const { components } = viewModel\n\n // Featured form component\n const [formComponent] = components.filter(\n ({ model }) => model.id === fileUpload.name\n )\n\n const index = components.indexOf(formComponent)\n\n const proxyUrl = getProxyUrlForLocalDevelopment(upload?.uploadUrl)\n\n return {\n ...viewModel,\n formAction: upload?.uploadUrl,\n uploadId: upload?.uploadId,\n formComponent,\n\n // Split out components before/after\n componentsBefore: components.slice(0, index),\n components: components.slice(index),\n proxyUrl\n }\n }\n\n /**\n * Refreshes the CDP upload and files in the\n * state and checks for any removed files.\n *\n * If an upload exists and hasn't been consumed\n * it gets re-used, otherwise we initiate a new one.\n * @param request - the hapi request\n * @param state - the form state\n */\n private async refreshUpload(\n request: AnyFormRequest,\n state: FormSubmissionState\n ) {\n state = await this.checkUploadStatus(request, state)\n\n return state\n }\n\n /**\n * If an upload exists and hasn't been consumed\n * it gets re-used, otherwise a new one is initiated.\n * @param request - the hapi request\n * @param state - the form state\n * @param depth - the number of retries so far\n */\n private async checkUploadStatus(\n request: AnyFormRequest,\n state: FormSubmissionState,\n depth = 1\n ): Promise<FormSubmissionState> {\n const upload = this.getUploadFromState(state)\n const files = this.getFilesFromState(state)\n\n // If no upload exists, initiate a new one.\n if (!upload?.uploadId) {\n return this.initiateAndStoreNewUpload(request, state)\n }\n\n const uploadId = upload.uploadId\n\n let statusResponse\n\n try {\n statusResponse = await getUploadStatus(uploadId)\n } catch (err) {\n // if the user loads a file upload page and queries the cached upload, after the upload has\n // expired in CDP, we will get a 404 from the getUploadStatus endpoint.\n // In this case we want to initiate a new upload and return that state, so the form\n // doesn't blow up for the end user.\n if (\n Boom.isBoom(err) &&\n err.output.statusCode === StatusCodes.NOT_FOUND.valueOf()\n ) {\n return this.initiateAndStoreNewUpload(request, state)\n }\n throw err\n }\n\n if (!statusResponse) {\n throw Boom.badRequest(\n `Unexpected empty response from getUploadStatus for ${uploadId}`\n )\n }\n\n // Re-use the upload if it is still in the \"initiated\" state.\n if (statusResponse.uploadStatus === UploadStatus.initiated) {\n return state\n }\n\n if (statusResponse.uploadStatus === UploadStatus.pending) {\n // Using exponential backoff delays:\n // Depth 1: 2000ms, Depth 2: 4000ms, Depth 3: 8000ms, Depth 4: 16000ms, Depth 5+: 30000ms (capped)\n // A depth of 5 (or more) implies cumulative delays roughly reaching 55 seconds.\n if (depth >= 5) {\n const err = new Error(\n `Exceeded cumulative retry delay for ${uploadId} (depth: ${depth}). Re-initiating a new upload.`\n )\n request.logger.error(\n err,\n `[uploadTimeout] Exceeded cumulative retry delay for uploadId: ${uploadId} at depth: ${depth} - re-initiating new upload`\n )\n await this.initiateAndStoreNewUpload(request, state)\n throw Boom.gatewayTimeout(\n `Timed out waiting for ${uploadId} after cumulative retries exceeding ${((CDP_UPLOAD_TIMEOUT_MS - 5000) / 1000).toFixed(0)} seconds`\n )\n }\n const delay = getExponentialBackoffDelay(depth)\n request.logger.info(\n `[uploadRetry] Waiting ${delay / 1000} seconds for uploadId: ${uploadId} to complete (retry depth: ${depth})`\n )\n await wait(delay)\n return this.checkUploadStatus(request, state, depth + 1)\n }\n\n // Only add to files state if the file validates.\n // This secures against html tampering of the file input\n // by adding a 'multiple' attribute or it being\n // changed to a simple text field or similar.\n const validationResult = tempItemSchema.validate(\n { uploadId, status: statusResponse },\n { stripUnknown: true }\n )\n const error = validationResult.error\n const fileState = validationResult.value as FileState\n\n if (error) {\n return this.initiateAndStoreNewUpload(request, state)\n }\n\n const file = fileState.status.form.file\n if (file.fileStatus === FileStatus.complete) {\n files.unshift(prepareFileState(fileState))\n await this.mergeState(request, state, {\n upload: { [this.path]: { files, upload } }\n })\n } else {\n // Flash the error message.\n const { fileUpload } = this\n const cacheService = getCacheService(request.server)\n\n const name = fileUpload.name\n const text = file.errorMessage ?? 'Unknown error'\n const errors: FormSubmissionError[] = [\n { path: [name], href: `#${name}`, name, text }\n ]\n cacheService.setFlash(request, { errors })\n }\n\n return this.initiateAndStoreNewUpload(request, state)\n }\n\n /**\n * Checks the payload for a file getting removed\n * and removes it from the upload files if found\n * @param request - the hapi request\n * @param state - the form state\n * @returns updated state if any files have been removed\n */\n private async checkRemovedFiles(\n request: FormRequestPayload,\n state: FormSubmissionState\n ) {\n const { path } = this\n const { params } = request\n\n const upload = this.getUploadFromState(state)\n const files = this.getFilesFromState(state)\n\n const filesUpdated = files.filter(\n ({ uploadId }) => uploadId !== params.itemId\n )\n\n if (filesUpdated.length === files.length) {\n return\n }\n\n await this.mergeState(request, state, {\n upload: { [path]: { files: filesUpdated, upload } }\n })\n }\n\n /**\n * Initiates a CDP file upload and stores in the upload state\n * @param request - the hapi request\n * @param state - the form state\n */\n private async initiateAndStoreNewUpload(\n request: AnyFormRequest,\n state: FormSubmissionState\n ) {\n const { fileUpload, href, path } = this\n const { options, schema } = fileUpload\n const { getFormMetadata } = this.model.services.formsService\n\n const files = this.getFilesFromState(state)\n\n // Reset the upload in state\n let upload: UploadInitiateResponse | undefined\n\n // Don't initiate anymore after minimum of `schema.max` or MAX_UPLOADS\n const max = Math.min(schema.max ?? MAX_UPLOADS, MAX_UPLOADS)\n\n if (files.length < max) {\n const formMetadata = await getFormMetadata(request.params.slug)\n const notificationEmail =\n formMetadata.notificationEmail ?? 'defraforms@defra.gov.uk'\n\n const newUpload = await initiateUpload(\n href,\n notificationEmail,\n options.accept\n )\n\n if (newUpload === undefined) {\n throw Boom.badRequest('Unexpected empty response from initiateUpload')\n }\n\n upload = newUpload\n }\n\n return this.mergeState(request, state, {\n upload: { [path]: { files, upload } }\n })\n }\n}\n"],"mappings":"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","ignoreList":[]}
|
|
1
|
+
{"version":3,"file":"FileUploadPageController.js","names":["ComponentType","Boom","wait","StatusCodes","FileUploadField","tempItemSchema","getCacheService","getError","getExponentialBackoffDelay","QuestionPageController","getProxyUrlForLocalDevelopment","getUploadStatus","initiateUpload","FileStatus","UploadStatus","MAX_UPLOADS","CDP_UPLOAD_TIMEOUT_MS","prepareStatus","status","file","form","isPending","fileStatus","pending","errorMessage","prepareFileState","fileState","FileUploadPageController","fileUpload","fileDeleteViewName","constructor","model","pageDef","collection","fileUploads","fields","filter","field","type","at","length","badImplementation","path","indexOf","name","viewName","getStateKeys","component","pagePath","page","getFormDataFromState","request","state","payload","files","getFilesFromState","undefined","getState","refreshUpload","uploadState","upload","getUploadFromState","makeGetItemDeleteRouteHandler","context","h","viewModel","params","fileToRemove","find","fileId","itemId","notFound","filename","view","backLink","getBackLink","pageTitle","itemTitle","confirmation","text","buttonConfirm","buttonCancel","makePostItemDeleteRouteHandler","confirm","getFormParams","checkRemovedFiles","proceed","getErrors","details","errors","forEach","error","isUploadError","isUploadRootError","push","value","href","getViewModel","components","formComponent","id","index","proxyUrl","uploadUrl","formAction","uploadId","componentsBefore","slice","checkUploadStatus","depth","initiateAndStoreNewUpload","statusResponse","err","isBoom","output","statusCode","NOT_FOUND","valueOf","badRequest","uploadStatus","initiated","Error","logger","gatewayTimeout","toFixed","delay","info","validationResult","validate","stripUnknown","processUploadedFiles","validatedItem","validatedStatus","rawFile","uploadedFiles","Array","isArray","allErrors","complete","perFileState","unshift","cacheService","server","setFlash","some","f","mergeState","filesUpdated","options","schema","getFormMetadata","services","formsService","max","Math","min","formMetadata","slug","notificationEmail","newUpload","accept"],"sources":["../../../../../src/server/plugins/engine/pageControllers/FileUploadPageController.ts"],"sourcesContent":["import { ComponentType, type PageFileUpload } from '@defra/forms-model'\nimport Boom from '@hapi/boom'\nimport { wait } from '@hapi/hoek'\nimport { StatusCodes } from 'http-status-codes'\nimport { type ValidationErrorItem } from 'joi'\n\nimport {\n FileUploadField,\n tempItemSchema\n} from '~/src/server/plugins/engine/components/FileUploadField.js'\nimport { type FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'\nimport {\n getCacheService,\n getError,\n getExponentialBackoffDelay\n} from '~/src/server/plugins/engine/helpers.js'\nimport { type FormModel } from '~/src/server/plugins/engine/models/index.js'\nimport { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js'\nimport { getProxyUrlForLocalDevelopment } from '~/src/server/plugins/engine/pageControllers/helpers/index.js'\nimport {\n getUploadStatus,\n initiateUpload\n} from '~/src/server/plugins/engine/services/uploadService.js'\nimport {\n FileStatus,\n UploadStatus,\n type AnyFormRequest,\n type FeaturedFormPageViewModel,\n type FileState,\n type FileUpload,\n type FormContext,\n type FormContextRequest,\n type FormSubmissionError,\n type FormSubmissionState,\n type ItemDeletePageViewModel,\n type UploadInitiateResponse,\n type UploadStatusFileResponse\n} from '~/src/server/plugins/engine/types.js'\nimport {\n type FormRequest,\n type FormRequestPayload,\n type FormResponseToolkit\n} from '~/src/server/routes/types.js'\n\nconst MAX_UPLOADS = 25\nconst CDP_UPLOAD_TIMEOUT_MS = 60000 // 1 minute\n\nexport function prepareStatus(status: UploadStatusFileResponse) {\n const file = status.form.file\n const isPending = file.fileStatus === FileStatus.pending\n\n if (!file.errorMessage && isPending) {\n file.errorMessage = 'The selected file has not fully uploaded'\n }\n\n return status\n}\n\nfunction prepareFileState(fileState: FileState) {\n prepareStatus(fileState.status)\n\n return fileState\n}\n\nexport class FileUploadPageController extends QuestionPageController {\n declare pageDef: PageFileUpload\n\n fileUpload: FileUploadField\n fileDeleteViewName = 'item-delete'\n\n constructor(model: FormModel, pageDef: PageFileUpload) {\n super(model, pageDef)\n\n const { collection } = this\n\n // Get the file upload fields from the collection\n const fileUploads = collection.fields.filter(\n (field): field is FileUploadField =>\n field.type === ComponentType.FileUploadField\n )\n\n const fileUpload = fileUploads.at(0)\n\n // Assert we have exactly 1 file upload component\n if (!fileUpload || fileUploads.length > 1) {\n throw Boom.badImplementation(\n `Expected 1 FileUploadFieldComponent in FileUploadPageController '${pageDef.path}'`\n )\n }\n\n // Assert the file upload component is the first form component\n if (collection.fields.indexOf(fileUpload) !== 0) {\n throw Boom.badImplementation(\n `Expected '${fileUpload.name}' to be the first form component in FileUploadPageController '${pageDef.path}'`\n )\n }\n\n // Assign the file upload component to the controller\n this.fileUpload = fileUpload\n this.viewName = 'file-upload'\n }\n\n /**\n * Get supplementary state keys for clearing file upload state.\n * Returns the nested upload path for FileUploadField components only.\n * @param component - The component to get supplementary state keys for\n * @returns Array containing the nested upload path, e.g., [\"upload['/page-path']\"]\n * or ['upload'] if no page path is available. Returns empty array for non-FileUploadField components.\n */\n getStateKeys(component: FormComponent): string[] {\n // Only return upload keys for FileUploadField components\n if (!(component instanceof FileUploadField)) {\n return []\n }\n\n const pagePath = component.page?.path\n return pagePath ? [`upload['${pagePath}']`] : ['upload']\n }\n\n getFormDataFromState(\n request: FormContextRequest | undefined,\n state: FormSubmissionState\n ) {\n const { fileUpload } = this\n\n const payload = super.getFormDataFromState(request, state)\n const files = this.getFilesFromState(state)\n\n // Append the files to the payload\n payload[fileUpload.name] = files.length ? files : undefined\n\n return payload\n }\n\n async getState(request: AnyFormRequest) {\n const { fileUpload } = this\n\n // Get the actual state\n const state = await super.getState(request)\n const files = this.getFilesFromState(state)\n\n // Overwrite the files with those in the upload state\n state[fileUpload.name] = files\n\n return this.refreshUpload(request, state)\n }\n\n /**\n * Get the uploaded files from state.\n */\n getFilesFromState(state: FormSubmissionState) {\n const { path } = this\n\n const uploadState = state.upload?.[path]\n return uploadState?.files ?? []\n }\n\n /**\n * Get the initiated upload from state.\n */\n getUploadFromState(state: FormSubmissionState) {\n const { path } = this\n\n const uploadState = state.upload?.[path]\n return uploadState?.upload\n }\n\n makeGetItemDeleteRouteHandler() {\n return (\n request: FormRequest,\n context: FormContext,\n h: FormResponseToolkit\n ) => {\n const { viewModel } = this\n const { params } = request\n const { state } = context\n\n const files = this.getFilesFromState(state)\n\n const fileToRemove = files.find(\n ({ status }) => status.form.file.fileId === params.itemId\n )\n\n if (!fileToRemove) {\n throw Boom.notFound('File to delete not found')\n }\n\n const { filename } = fileToRemove.status.form.file\n\n return h.view(this.fileDeleteViewName, {\n ...viewModel,\n context,\n backLink: this.getBackLink(request, context),\n pageTitle: `Are you sure you want to remove this file?`,\n itemTitle: filename,\n confirmation: { text: 'You cannot recover removed files.' },\n buttonConfirm: { text: 'Remove file' },\n buttonCancel: { text: 'Cancel' }\n } satisfies ItemDeletePageViewModel)\n }\n }\n\n makePostItemDeleteRouteHandler() {\n return async (\n request: FormRequestPayload,\n context: FormContext,\n h: FormResponseToolkit\n ) => {\n const { path } = this\n const { state } = context\n\n const { confirm } = this.getFormParams(request)\n\n // Check for any removed files in the POST payload\n if (confirm) {\n await this.checkRemovedFiles(request, state)\n return this.proceed(request, h, path)\n }\n\n return this.proceed(request, h)\n }\n }\n\n getErrors(details?: ValidationErrorItem[]) {\n const { fileUpload } = this\n\n if (details) {\n const errors: FormSubmissionError[] = []\n\n details.forEach((error) => {\n const isUploadError = error.path[0] === fileUpload.name\n const isUploadRootError = isUploadError && error.path.length === 1\n\n if (!isUploadError || isUploadRootError) {\n // The error is for the root of the upload or another\n // field on the page so defer to the getError helper\n errors.push(getError(error))\n } else {\n const { context, path, type } = error\n\n if (type === 'object.unknown' && path.at(-1) === 'errorMessage') {\n const value = context?.value as string | undefined\n\n if (value) {\n const name = fileUpload.name\n const text = typeof value === 'string' ? value : 'Unknown error'\n const href = `#${name}`\n\n errors.push({ path, href, name, text })\n }\n }\n }\n })\n\n return errors\n }\n }\n\n getViewModel(\n request: FormContextRequest,\n context: FormContext\n ): FeaturedFormPageViewModel {\n const { fileUpload } = this\n const { state } = context\n\n const upload = this.getUploadFromState(state)\n\n const viewModel = super.getViewModel(request, context)\n const { components } = viewModel\n\n // Featured form component\n const [formComponent] = components.filter(\n ({ model }) => model.id === fileUpload.name\n )\n\n const index = components.indexOf(formComponent)\n\n const proxyUrl = getProxyUrlForLocalDevelopment(upload?.uploadUrl)\n\n return {\n ...viewModel,\n formAction: upload?.uploadUrl,\n uploadId: upload?.uploadId,\n formComponent,\n\n // Split out components before/after\n componentsBefore: components.slice(0, index),\n components: components.slice(index),\n proxyUrl\n }\n }\n\n /**\n * Refreshes the CDP upload and files in the\n * state and checks for any removed files.\n *\n * If an upload exists and hasn't been consumed\n * it gets re-used, otherwise we initiate a new one.\n * @param request - the hapi request\n * @param state - the form state\n */\n private async refreshUpload(\n request: AnyFormRequest,\n state: FormSubmissionState\n ) {\n state = await this.checkUploadStatus(request, state)\n\n return state\n }\n\n /**\n * If an upload exists and hasn't been consumed\n * it gets re-used, otherwise a new one is initiated.\n * @param request - the hapi request\n * @param state - the form state\n * @param depth - the number of retries so far\n */\n private async checkUploadStatus(\n request: AnyFormRequest,\n state: FormSubmissionState,\n depth = 1\n ): Promise<FormSubmissionState> {\n const upload = this.getUploadFromState(state)\n const files = this.getFilesFromState(state)\n\n // If no upload exists, initiate a new one.\n if (!upload?.uploadId) {\n return this.initiateAndStoreNewUpload(request, state)\n }\n\n const uploadId = upload.uploadId\n\n let statusResponse\n\n try {\n statusResponse = await getUploadStatus(uploadId)\n } catch (err) {\n // if the user loads a file upload page and queries the cached upload, after the upload has\n // expired in CDP, we will get a 404 from the getUploadStatus endpoint.\n // In this case we want to initiate a new upload and return that state, so the form\n // doesn't blow up for the end user.\n if (\n Boom.isBoom(err) &&\n err.output.statusCode === StatusCodes.NOT_FOUND.valueOf()\n ) {\n return this.initiateAndStoreNewUpload(request, state)\n }\n throw err\n }\n\n if (!statusResponse) {\n throw Boom.badRequest(\n `Unexpected empty response from getUploadStatus for ${uploadId}`\n )\n }\n\n // Re-use the upload if it is still in the \"initiated\" state.\n if (statusResponse.uploadStatus === UploadStatus.initiated) {\n return state\n }\n\n if (statusResponse.uploadStatus === UploadStatus.pending) {\n // Using exponential backoff delays:\n // Depth 1: 2000ms, Depth 2: 4000ms, Depth 3: 8000ms, Depth 4: 16000ms, Depth 5+: 30000ms (capped)\n // A depth of 5 (or more) implies cumulative delays roughly reaching 55 seconds.\n if (depth >= 5) {\n const err = new Error(\n `Exceeded cumulative retry delay for ${uploadId} (depth: ${depth}). Re-initiating a new upload.`\n )\n request.logger.error(\n err,\n `[uploadTimeout] Exceeded cumulative retry delay for uploadId: ${uploadId} at depth: ${depth} - re-initiating new upload`\n )\n await this.initiateAndStoreNewUpload(request, state)\n throw Boom.gatewayTimeout(\n `Timed out waiting for ${uploadId} after cumulative retries exceeding ${((CDP_UPLOAD_TIMEOUT_MS - 5000) / 1000).toFixed(0)} seconds`\n )\n }\n const delay = getExponentialBackoffDelay(depth)\n request.logger.info(\n `[uploadRetry] Waiting ${delay / 1000} seconds for uploadId: ${uploadId} to complete (retry depth: ${depth})`\n )\n await wait(delay)\n return this.checkUploadStatus(request, state, depth + 1)\n }\n\n // Only add to files state if the file validates.\n // This secures against html tampering of the file input\n // (e.g. changing it to a simple text field or similar).\n const validationResult = tempItemSchema.validate(\n { uploadId, status: statusResponse },\n { stripUnknown: true }\n )\n const error = validationResult.error\n\n if (error) {\n return this.initiateAndStoreNewUpload(request, state)\n }\n\n // CDP returns form.file as a single object for one file,\n // or an array for multiple files. The Joi schema normalises\n // both to an array via .single().\n await this.processUploadedFiles(\n request,\n state,\n validationResult.value,\n files,\n upload\n )\n\n return this.initiateAndStoreNewUpload(request, state)\n }\n\n /**\n * Processes the uploaded files from a CDP status response.\n * Complete files are added to state, rejected/pending files\n * have their error messages flashed.\n * @param request - the hapi request\n * @param state - the form state\n * @param validatedItem - the Joi-validated upload item\n * @param files - the current files array from state\n * @param upload - the current upload initiation response\n */\n private async processUploadedFiles(\n request: AnyFormRequest,\n state: FormSubmissionState,\n validatedItem: FileState,\n files: FileState[],\n upload: UploadInitiateResponse | undefined\n ) {\n const { uploadId } = validatedItem\n const validatedStatus = validatedItem.status\n const rawFile = validatedStatus.form.file as unknown as\n | FileUpload\n | FileUpload[]\n const uploadedFiles = Array.isArray(rawFile) ? rawFile : [rawFile]\n\n const allErrors: FormSubmissionError[] = []\n\n for (const file of uploadedFiles) {\n if (file.fileStatus === FileStatus.complete) {\n const perFileState: FileState = {\n uploadId,\n status: {\n ...validatedStatus,\n form: { file }\n } as FileState['status']\n }\n files.unshift(prepareFileState(perFileState))\n } else {\n // Collect the error for rejected/pending files.\n const { fileUpload } = this\n const name = fileUpload.name\n const text = file.errorMessage ?? 'Unknown error'\n allErrors.push({ path: [name], href: `#${name}`, name, text })\n }\n }\n\n if (allErrors.length) {\n const cacheService = getCacheService(request.server)\n cacheService.setFlash(request, { errors: allErrors })\n }\n\n if (uploadedFiles.some((f) => f.fileStatus === FileStatus.complete)) {\n await this.mergeState(request, state, {\n upload: { [this.path]: { files, upload } }\n })\n }\n }\n\n /**\n * Checks the payload for a file getting removed\n * and removes it from the upload files if found\n * @param request - the hapi request\n * @param state - the form state\n * @returns updated state if any files have been removed\n */\n private async checkRemovedFiles(\n request: FormRequestPayload,\n state: FormSubmissionState\n ) {\n const { path } = this\n const { params } = request\n\n const upload = this.getUploadFromState(state)\n const files = this.getFilesFromState(state)\n\n const filesUpdated = files.filter(\n ({ status }) => status.form.file.fileId !== params.itemId\n )\n\n if (filesUpdated.length === files.length) {\n return\n }\n\n await this.mergeState(request, state, {\n upload: { [path]: { files: filesUpdated, upload } }\n })\n }\n\n /**\n * Initiates a CDP file upload and stores in the upload state\n * @param request - the hapi request\n * @param state - the form state\n */\n private async initiateAndStoreNewUpload(\n request: AnyFormRequest,\n state: FormSubmissionState\n ) {\n const { fileUpload, href, path } = this\n const { options, schema } = fileUpload\n const { getFormMetadata } = this.model.services.formsService\n\n const files = this.getFilesFromState(state)\n\n // Reset the upload in state\n let upload: UploadInitiateResponse | undefined\n\n // Don't initiate anymore after minimum of `schema.max` or MAX_UPLOADS\n const max = Math.min(schema.max ?? MAX_UPLOADS, MAX_UPLOADS)\n\n if (files.length < max) {\n const formMetadata = await getFormMetadata(request.params.slug)\n const notificationEmail =\n formMetadata.notificationEmail ?? 'defraforms@defra.gov.uk'\n\n const newUpload = await initiateUpload(\n href,\n notificationEmail,\n options.accept\n )\n\n if (newUpload === undefined) {\n throw Boom.badRequest('Unexpected empty response from initiateUpload')\n }\n\n upload = newUpload\n }\n\n return this.mergeState(request, state, {\n upload: { [path]: { files, upload } }\n })\n }\n}\n"],"mappings":"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","ignoreList":[]}
|
package/package.json
CHANGED
|
@@ -304,16 +304,19 @@ function pollUploadStatus(uploadId) {
|
|
|
304
304
|
* @param {HTMLInputElement} fileInput - The file input element
|
|
305
305
|
* @param {HTMLButtonElement} uploadButton - The upload button
|
|
306
306
|
* @param {HTMLButtonElement} continueButton - The continue button
|
|
307
|
-
* @param {File
|
|
307
|
+
* @param {File[]} selectedFiles - The selected files
|
|
308
308
|
*/
|
|
309
309
|
function handleStandardFormSubmission(
|
|
310
310
|
formElement,
|
|
311
311
|
fileInput,
|
|
312
312
|
uploadButton,
|
|
313
313
|
continueButton,
|
|
314
|
-
|
|
314
|
+
selectedFiles
|
|
315
315
|
) {
|
|
316
|
-
|
|
316
|
+
// Render in reverse so first file ends up at the top of the summary list
|
|
317
|
+
for (let i = selectedFiles.length - 1; i >= 0; i--) {
|
|
318
|
+
renderSummary(selectedFiles[i], 'Uploading…', formElement)
|
|
319
|
+
}
|
|
317
320
|
|
|
318
321
|
fileInput.focus()
|
|
319
322
|
|
|
@@ -403,8 +406,8 @@ function initUpload() {
|
|
|
403
406
|
}
|
|
404
407
|
|
|
405
408
|
const formElement = /** @type {HTMLFormElement} */ (form)
|
|
406
|
-
/** @type {File
|
|
407
|
-
let
|
|
409
|
+
/** @type {File[]} */
|
|
410
|
+
let selectedFiles = []
|
|
408
411
|
let isSubmitting = false
|
|
409
412
|
const uploadId = formElement.dataset.uploadId
|
|
410
413
|
|
|
@@ -414,12 +417,12 @@ function initUpload() {
|
|
|
414
417
|
}
|
|
415
418
|
|
|
416
419
|
if (fileInput.files && fileInput.files.length > 0) {
|
|
417
|
-
|
|
420
|
+
selectedFiles = Array.from(fileInput.files)
|
|
418
421
|
}
|
|
419
422
|
})
|
|
420
423
|
|
|
421
424
|
uploadButton.addEventListener('click', (event) => {
|
|
422
|
-
if (
|
|
425
|
+
if (selectedFiles.length === 0) {
|
|
423
426
|
event.preventDefault()
|
|
424
427
|
showError(
|
|
425
428
|
'Select a file',
|
|
@@ -436,12 +439,13 @@ function initUpload() {
|
|
|
436
439
|
|
|
437
440
|
isSubmitting = true
|
|
438
441
|
|
|
442
|
+
// Show all selected files in the summary table
|
|
439
443
|
handleStandardFormSubmission(
|
|
440
444
|
formElement,
|
|
441
445
|
fileInput,
|
|
442
446
|
uploadButton,
|
|
443
447
|
continueButton,
|
|
444
|
-
|
|
448
|
+
selectedFiles
|
|
445
449
|
)
|
|
446
450
|
|
|
447
451
|
handleAjaxFormSubmission(
|
|
@@ -405,7 +405,7 @@ describe('FileUploadField', () => {
|
|
|
405
405
|
actions: {
|
|
406
406
|
items: [
|
|
407
407
|
{
|
|
408
|
-
href: `/test/file-upload-component/${validState[0].
|
|
408
|
+
href: `/test/file-upload-component/${validState[0].status.form.file.fileId}/confirm-delete`,
|
|
409
409
|
text: 'Remove',
|
|
410
410
|
attributes: { id: 'myComponent__0' },
|
|
411
411
|
classes: 'govuk-link--no-visited-state',
|
|
@@ -424,7 +424,7 @@ describe('FileUploadField', () => {
|
|
|
424
424
|
actions: {
|
|
425
425
|
items: [
|
|
426
426
|
{
|
|
427
|
-
href: `/test/file-upload-component/${validState[1].
|
|
427
|
+
href: `/test/file-upload-component/${validState[1].status.form.file.fileId}/confirm-delete`,
|
|
428
428
|
text: 'Remove',
|
|
429
429
|
attributes: { id: 'myComponent__1' },
|
|
430
430
|
classes: 'govuk-link--no-visited-state',
|
|
@@ -443,7 +443,7 @@ describe('FileUploadField', () => {
|
|
|
443
443
|
actions: {
|
|
444
444
|
items: [
|
|
445
445
|
{
|
|
446
|
-
href: `/test/file-upload-component/${validState[2].
|
|
446
|
+
href: `/test/file-upload-component/${validState[2].status.form.file.fileId}/confirm-delete`,
|
|
447
447
|
text: 'Remove',
|
|
448
448
|
attributes: { id: 'myComponent__2' },
|
|
449
449
|
classes: 'govuk-link--no-visited-state',
|
|
@@ -454,7 +454,8 @@ describe('FileUploadField', () => {
|
|
|
454
454
|
}
|
|
455
455
|
]
|
|
456
456
|
}
|
|
457
|
-
}
|
|
457
|
+
},
|
|
458
|
+
multiple: true
|
|
458
459
|
})
|
|
459
460
|
)
|
|
460
461
|
})
|
|
@@ -543,7 +544,7 @@ describe('FileUploadField', () => {
|
|
|
543
544
|
actions: {
|
|
544
545
|
items: [
|
|
545
546
|
{
|
|
546
|
-
href: `/test/file-upload-component/${validState[2].
|
|
547
|
+
href: `/test/file-upload-component/${validState[2].status.form.file.fileId}/confirm-delete`,
|
|
547
548
|
text: 'Remove',
|
|
548
549
|
attributes: { id: 'myComponent__0' },
|
|
549
550
|
classes: 'govuk-link--no-visited-state',
|
|
@@ -554,7 +555,8 @@ describe('FileUploadField', () => {
|
|
|
554
555
|
}
|
|
555
556
|
]
|
|
556
557
|
}
|
|
557
|
-
}
|
|
558
|
+
},
|
|
559
|
+
multiple: true
|
|
558
560
|
})
|
|
559
561
|
)
|
|
560
562
|
})
|
|
@@ -583,7 +585,7 @@ describe('FileUploadField', () => {
|
|
|
583
585
|
actions: {
|
|
584
586
|
items: [
|
|
585
587
|
{
|
|
586
|
-
href: `/test/file-upload-component/${validState[2].
|
|
588
|
+
href: `/test/file-upload-component/${validState[2].status.form.file.fileId}/confirm-delete`,
|
|
587
589
|
text: 'Remove',
|
|
588
590
|
attributes: { id: 'myComponent__0' },
|
|
589
591
|
classes: 'govuk-link--no-visited-state',
|
|
@@ -594,7 +596,8 @@ describe('FileUploadField', () => {
|
|
|
594
596
|
}
|
|
595
597
|
]
|
|
596
598
|
}
|
|
597
|
-
}
|
|
599
|
+
},
|
|
600
|
+
multiple: true
|
|
598
601
|
})
|
|
599
602
|
)
|
|
600
603
|
})
|
|
@@ -73,9 +73,12 @@ export const tempStatusSchema = joi
|
|
|
73
73
|
.valid(UploadStatus.ready, UploadStatus.pending)
|
|
74
74
|
.required(),
|
|
75
75
|
metadata: metadataSchema,
|
|
76
|
-
form: joi
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
form: joi
|
|
77
|
+
.object()
|
|
78
|
+
.required()
|
|
79
|
+
.keys({
|
|
80
|
+
file: joi.array().items(tempFileSchema).single().required()
|
|
81
|
+
}),
|
|
79
82
|
numberOfRejectedFiles: joi.number().optional()
|
|
80
83
|
})
|
|
81
84
|
.required()
|
|
@@ -191,7 +194,7 @@ export class FileUploadField extends FormComponent {
|
|
|
191
194
|
errors?: FormSubmissionError[],
|
|
192
195
|
query: FormQuery = {}
|
|
193
196
|
) {
|
|
194
|
-
const { options, page } = this
|
|
197
|
+
const { options, page, schema } = this
|
|
195
198
|
|
|
196
199
|
// Allow preview URL direct access
|
|
197
200
|
const isForceAccess = 'force' in query
|
|
@@ -233,7 +236,7 @@ export class FileUploadField extends FormComponent {
|
|
|
233
236
|
|
|
234
237
|
// Remove summary list actions from previews
|
|
235
238
|
if (!isForceAccess) {
|
|
236
|
-
const path = `/${
|
|
239
|
+
const path = `/${file.fileId}/confirm-delete`
|
|
237
240
|
const href = page?.getHref(`${page.path}${path}`) ?? '#'
|
|
238
241
|
|
|
239
242
|
items.push({
|
|
@@ -263,6 +266,9 @@ export class FileUploadField extends FormComponent {
|
|
|
263
266
|
attributes.accept = options.accept
|
|
264
267
|
}
|
|
265
268
|
|
|
269
|
+
// Allow multiple file selection when schema permits more than 1 file
|
|
270
|
+
const allowsMultiple = schema.max !== 1 && schema.length !== 1
|
|
271
|
+
|
|
266
272
|
const summaryList: SummaryList = {
|
|
267
273
|
classes: 'govuk-summary-list--long-key',
|
|
268
274
|
rows
|
|
@@ -277,6 +283,9 @@ export class FileUploadField extends FormComponent {
|
|
|
277
283
|
// Override the component name we send to CDP
|
|
278
284
|
name: 'file',
|
|
279
285
|
|
|
286
|
+
// Enable multi-file selection in the file picker
|
|
287
|
+
...(allowsMultiple && { multiple: true }),
|
|
288
|
+
|
|
280
289
|
upload: {
|
|
281
290
|
count,
|
|
282
291
|
summaryList
|
|
@@ -795,6 +795,83 @@ describe('FileUploadPageController', () => {
|
|
|
795
795
|
})
|
|
796
796
|
})
|
|
797
797
|
|
|
798
|
+
it('collects all file errors into a single flash when multiple files fail', async () => {
|
|
799
|
+
const state = {
|
|
800
|
+
upload: {
|
|
801
|
+
[controller.path]: {
|
|
802
|
+
upload: {
|
|
803
|
+
uploadId: 'some-id',
|
|
804
|
+
uploadUrl: 'some-url',
|
|
805
|
+
statusUrl: 'some-status-url'
|
|
806
|
+
},
|
|
807
|
+
files: []
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
} as unknown as FormSubmissionState
|
|
811
|
+
|
|
812
|
+
const errorStatus = {
|
|
813
|
+
uploadStatus: UploadStatus.ready,
|
|
814
|
+
form: {
|
|
815
|
+
file: [
|
|
816
|
+
{
|
|
817
|
+
fileStatus: FileStatus.rejected,
|
|
818
|
+
errorMessage: 'File too large'
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
fileStatus: FileStatus.rejected,
|
|
822
|
+
errorMessage: 'Invalid file type'
|
|
823
|
+
}
|
|
824
|
+
]
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
jest
|
|
829
|
+
.spyOn(uploadService, 'getUploadStatus')
|
|
830
|
+
.mockResolvedValue(errorStatus as unknown as UploadStatusResponse)
|
|
831
|
+
|
|
832
|
+
jest.spyOn(tempItemSchema, 'validate').mockReturnValue({
|
|
833
|
+
value: {
|
|
834
|
+
status: errorStatus,
|
|
835
|
+
uploadId: 'some-id'
|
|
836
|
+
},
|
|
837
|
+
error: undefined
|
|
838
|
+
} as ValidationResult)
|
|
839
|
+
|
|
840
|
+
const testController = controller as TestableFileUploadPageController
|
|
841
|
+
|
|
842
|
+
const initiateSpy = jest.spyOn(
|
|
843
|
+
testController,
|
|
844
|
+
'initiateAndStoreNewUpload'
|
|
845
|
+
) as jest.SpyInstance<
|
|
846
|
+
Promise<FormSubmissionState>,
|
|
847
|
+
[FormRequest, FormSubmissionState]
|
|
848
|
+
>
|
|
849
|
+
|
|
850
|
+
initiateSpy.mockResolvedValue(state)
|
|
851
|
+
|
|
852
|
+
const cacheService = getCacheService(request.server)
|
|
853
|
+
|
|
854
|
+
await controller['checkUploadStatus'](request, state, 1)
|
|
855
|
+
|
|
856
|
+
expect(cacheService.setFlash).toHaveBeenCalledTimes(1)
|
|
857
|
+
expect(cacheService.setFlash).toHaveBeenCalledWith(request, {
|
|
858
|
+
errors: [
|
|
859
|
+
{
|
|
860
|
+
path: ['fileUpload'],
|
|
861
|
+
href: '#fileUpload',
|
|
862
|
+
name: 'fileUpload',
|
|
863
|
+
text: 'File too large'
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
path: ['fileUpload'],
|
|
867
|
+
href: '#fileUpload',
|
|
868
|
+
name: 'fileUpload',
|
|
869
|
+
text: 'Invalid file type'
|
|
870
|
+
}
|
|
871
|
+
]
|
|
872
|
+
})
|
|
873
|
+
})
|
|
874
|
+
|
|
798
875
|
it('sets default error message when none provided', async () => {
|
|
799
876
|
const state = {
|
|
800
877
|
upload: {
|
|
@@ -859,7 +936,16 @@ describe('FileUploadPageController', () => {
|
|
|
859
936
|
|
|
860
937
|
describe('file removal', () => {
|
|
861
938
|
it('returns early when no file is removed', async () => {
|
|
862
|
-
const files = [
|
|
939
|
+
const files = [
|
|
940
|
+
{
|
|
941
|
+
uploadId: 'upload1',
|
|
942
|
+
status: { form: { file: { fileId: 'file1' } } }
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
uploadId: 'upload2',
|
|
946
|
+
status: { form: { file: { fileId: 'file2' } } }
|
|
947
|
+
}
|
|
948
|
+
]
|
|
863
949
|
|
|
864
950
|
Object.defineProperty(request, 'params', {
|
|
865
951
|
value: { itemId: 'nonexistent-file' },
|
|
@@ -892,7 +978,16 @@ describe('FileUploadPageController', () => {
|
|
|
892
978
|
})
|
|
893
979
|
|
|
894
980
|
it('merges state when file is removed', async () => {
|
|
895
|
-
const files = [
|
|
981
|
+
const files = [
|
|
982
|
+
{
|
|
983
|
+
uploadId: 'upload1',
|
|
984
|
+
status: { form: { file: { fileId: 'file1' } } }
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
uploadId: 'upload2',
|
|
988
|
+
status: { form: { file: { fileId: 'file2' } } }
|
|
989
|
+
}
|
|
990
|
+
]
|
|
896
991
|
|
|
897
992
|
Object.defineProperty(request, 'params', {
|
|
898
993
|
value: { itemId: 'file1' },
|
|
@@ -924,7 +1019,12 @@ describe('FileUploadPageController', () => {
|
|
|
924
1019
|
expect(mergeStateSpy).toHaveBeenCalledWith(request, state, {
|
|
925
1020
|
upload: {
|
|
926
1021
|
[controller.path]: {
|
|
927
|
-
files: [
|
|
1022
|
+
files: [
|
|
1023
|
+
{
|
|
1024
|
+
uploadId: 'upload2',
|
|
1025
|
+
status: { form: { file: { fileId: 'file2' } } }
|
|
1026
|
+
}
|
|
1027
|
+
],
|
|
928
1028
|
upload: {
|
|
929
1029
|
uploadId: 'upload-123',
|
|
930
1030
|
uploadUrl: 'some-url',
|
|
@@ -1121,11 +1221,15 @@ describe('FileUploadPageController', () => {
|
|
|
1121
1221
|
files: [
|
|
1122
1222
|
{
|
|
1123
1223
|
uploadId: 'file-1',
|
|
1124
|
-
status: {
|
|
1224
|
+
status: {
|
|
1225
|
+
form: { file: { fileId: 'file-1', filename: 'file-1.pdf' } }
|
|
1226
|
+
}
|
|
1125
1227
|
},
|
|
1126
1228
|
{
|
|
1127
1229
|
uploadId: 'file-2',
|
|
1128
|
-
status: {
|
|
1230
|
+
status: {
|
|
1231
|
+
form: { file: { fileId: 'file-2', filename: 'file-2.pdf' } }
|
|
1232
|
+
}
|
|
1129
1233
|
}
|
|
1130
1234
|
]
|
|
1131
1235
|
}
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type AnyFormRequest,
|
|
28
28
|
type FeaturedFormPageViewModel,
|
|
29
29
|
type FileState,
|
|
30
|
+
type FileUpload,
|
|
30
31
|
type FormContext,
|
|
31
32
|
type FormContextRequest,
|
|
32
33
|
type FormSubmissionError,
|
|
@@ -177,7 +178,7 @@ export class FileUploadPageController extends QuestionPageController {
|
|
|
177
178
|
const files = this.getFilesFromState(state)
|
|
178
179
|
|
|
179
180
|
const fileToRemove = files.find(
|
|
180
|
-
({
|
|
181
|
+
({ status }) => status.form.file.fileId === params.itemId
|
|
181
182
|
)
|
|
182
183
|
|
|
183
184
|
if (!fileToRemove) {
|
|
@@ -385,39 +386,86 @@ export class FileUploadPageController extends QuestionPageController {
|
|
|
385
386
|
|
|
386
387
|
// Only add to files state if the file validates.
|
|
387
388
|
// This secures against html tampering of the file input
|
|
388
|
-
//
|
|
389
|
-
// changed to a simple text field or similar.
|
|
389
|
+
// (e.g. changing it to a simple text field or similar).
|
|
390
390
|
const validationResult = tempItemSchema.validate(
|
|
391
391
|
{ uploadId, status: statusResponse },
|
|
392
392
|
{ stripUnknown: true }
|
|
393
393
|
)
|
|
394
394
|
const error = validationResult.error
|
|
395
|
-
const fileState = validationResult.value as FileState
|
|
396
395
|
|
|
397
396
|
if (error) {
|
|
398
397
|
return this.initiateAndStoreNewUpload(request, state)
|
|
399
398
|
}
|
|
400
399
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
400
|
+
// CDP returns form.file as a single object for one file,
|
|
401
|
+
// or an array for multiple files. The Joi schema normalises
|
|
402
|
+
// both to an array via .single().
|
|
403
|
+
await this.processUploadedFiles(
|
|
404
|
+
request,
|
|
405
|
+
state,
|
|
406
|
+
validationResult.value,
|
|
407
|
+
files,
|
|
408
|
+
upload
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
return this.initiateAndStoreNewUpload(request, state)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Processes the uploaded files from a CDP status response.
|
|
416
|
+
* Complete files are added to state, rejected/pending files
|
|
417
|
+
* have their error messages flashed.
|
|
418
|
+
* @param request - the hapi request
|
|
419
|
+
* @param state - the form state
|
|
420
|
+
* @param validatedItem - the Joi-validated upload item
|
|
421
|
+
* @param files - the current files array from state
|
|
422
|
+
* @param upload - the current upload initiation response
|
|
423
|
+
*/
|
|
424
|
+
private async processUploadedFiles(
|
|
425
|
+
request: AnyFormRequest,
|
|
426
|
+
state: FormSubmissionState,
|
|
427
|
+
validatedItem: FileState,
|
|
428
|
+
files: FileState[],
|
|
429
|
+
upload: UploadInitiateResponse | undefined
|
|
430
|
+
) {
|
|
431
|
+
const { uploadId } = validatedItem
|
|
432
|
+
const validatedStatus = validatedItem.status
|
|
433
|
+
const rawFile = validatedStatus.form.file as unknown as
|
|
434
|
+
| FileUpload
|
|
435
|
+
| FileUpload[]
|
|
436
|
+
const uploadedFiles = Array.isArray(rawFile) ? rawFile : [rawFile]
|
|
437
|
+
|
|
438
|
+
const allErrors: FormSubmissionError[] = []
|
|
439
|
+
|
|
440
|
+
for (const file of uploadedFiles) {
|
|
441
|
+
if (file.fileStatus === FileStatus.complete) {
|
|
442
|
+
const perFileState: FileState = {
|
|
443
|
+
uploadId,
|
|
444
|
+
status: {
|
|
445
|
+
...validatedStatus,
|
|
446
|
+
form: { file }
|
|
447
|
+
} as FileState['status']
|
|
448
|
+
}
|
|
449
|
+
files.unshift(prepareFileState(perFileState))
|
|
450
|
+
} else {
|
|
451
|
+
// Collect the error for rejected/pending files.
|
|
452
|
+
const { fileUpload } = this
|
|
453
|
+
const name = fileUpload.name
|
|
454
|
+
const text = file.errorMessage ?? 'Unknown error'
|
|
455
|
+
allErrors.push({ path: [name], href: `#${name}`, name, text })
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (allErrors.length) {
|
|
460
|
+
const cacheService = getCacheService(request.server)
|
|
461
|
+
cacheService.setFlash(request, { errors: allErrors })
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (uploadedFiles.some((f) => f.fileStatus === FileStatus.complete)) {
|
|
404
465
|
await this.mergeState(request, state, {
|
|
405
466
|
upload: { [this.path]: { files, upload } }
|
|
406
467
|
})
|
|
407
|
-
} else {
|
|
408
|
-
// Flash the error message.
|
|
409
|
-
const { fileUpload } = this
|
|
410
|
-
const cacheService = getCacheService(request.server)
|
|
411
|
-
|
|
412
|
-
const name = fileUpload.name
|
|
413
|
-
const text = file.errorMessage ?? 'Unknown error'
|
|
414
|
-
const errors: FormSubmissionError[] = [
|
|
415
|
-
{ path: [name], href: `#${name}`, name, text }
|
|
416
|
-
]
|
|
417
|
-
cacheService.setFlash(request, { errors })
|
|
418
468
|
}
|
|
419
|
-
|
|
420
|
-
return this.initiateAndStoreNewUpload(request, state)
|
|
421
469
|
}
|
|
422
470
|
|
|
423
471
|
/**
|
|
@@ -438,7 +486,7 @@ export class FileUploadPageController extends QuestionPageController {
|
|
|
438
486
|
const files = this.getFilesFromState(state)
|
|
439
487
|
|
|
440
488
|
const filesUpdated = files.filter(
|
|
441
|
-
({
|
|
489
|
+
({ status }) => status.form.file.fileId !== params.itemId
|
|
442
490
|
)
|
|
443
491
|
|
|
444
492
|
if (filesUpdated.length === files.length) {
|