diff --git a/src/server/forms/simple-form.yaml b/src/server/forms/simple-form.yaml index 2f72d1fe..efa8f821 100644 --- a/src/server/forms/simple-form.yaml +++ b/src/server/forms/simple-form.yaml @@ -41,6 +41,20 @@ pages: schema: {} id: 987c1234-56d7-89e0-1234-56789abcdef0 id: 23456789-0abc-def1-2345-67890abcdef1 + - title: Upload a copy of your drivers licence + controller: FileUploadPageController + path: '/upload-driving-licence' + components: + - type: FileUploadField + title: Please upload a copy of your drivers licence + name: driversLicenceUpload + shortDescription: Upload drivers licence + hint: '' + options: + required: true + schema: {} + id: 987c1234-56d7-89e0-1234-56789abcdef1 + id: 23456789-0abc-def1-2345-67890abcdef2 - title: '' path: '/date-of-birth' components: diff --git a/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts b/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts index 14f82d0b..32315eb9 100644 --- a/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts @@ -2,7 +2,11 @@ import { ComponentType, type ComponentDef } from '@defra/forms-model' import { type ValidationErrorItem, type ValidationResult } from 'joi' -import { tempItemSchema } from '~/src/server/plugins/engine/components/FileUploadField.js' +import { + FileUploadField, + tempItemSchema +} from '~/src/server/plugins/engine/components/FileUploadField.js' +import { TextField } from '~/src/server/plugins/engine/components/TextField.js' import { getCacheService, getError @@ -1133,4 +1137,48 @@ describe('FileUploadPageController', () => { expect(controller.shouldShowSaveAndExit(serverWithSaveAndExit)).toBe(true) }) }) + + describe('getStateKeys', () => { + it('should return nested upload path for FileUploadField component', () => { + const component = controller.fileUpload + const stateKeys = controller.getStateKeys(component) + + expect(stateKeys).toEqual(["upload['/file-upload-component']"]) + }) + + it('should return empty array for non-FileUploadField components', () => { + const component = new TextField( + { + name: 'testField', + title: 'Test field', + type: ComponentType.TextField, + options: {}, + schema: {} + }, + { model, page: controller } + ) + + const stateKeys = controller.getStateKeys(component) + expect(stateKeys).toEqual([]) + }) + + it('should return fallback upload key when component has no page', () => { + const componentDef: ComponentDef = { + name: 'fileUpload', + title: 'Upload something', + type: ComponentType.FileUploadField, + options: {}, + schema: {} + } + + // Create a component without a page reference - should return ['upload'] + const component = new FileUploadField(componentDef, { + model, + page: undefined + }) + + const stateKeys = controller.getStateKeys(component) + expect(stateKeys).toEqual(['upload']) + }) + }) }) diff --git a/src/server/plugins/engine/pageControllers/FileUploadPageController.ts b/src/server/plugins/engine/pageControllers/FileUploadPageController.ts index 928ba86f..79e36ca6 100644 --- a/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +++ b/src/server/plugins/engine/pageControllers/FileUploadPageController.ts @@ -4,9 +4,10 @@ import { wait } from '@hapi/hoek' import { type ValidationErrorItem } from 'joi' import { - tempItemSchema, - type FileUploadField + FileUploadField, + tempItemSchema } from '~/src/server/plugins/engine/components/FileUploadField.js' +import { type FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { getCacheService, getError, @@ -97,6 +98,23 @@ export class FileUploadPageController extends QuestionPageController { this.viewName = 'file-upload' } + /** + * Get supplementary state keys for clearing file upload state. + * Returns the nested upload path for FileUploadField components only. + * @param component - The component to get supplementary state keys for + * @returns Array containing the nested upload path, e.g., ["upload['/page-path']"] + * or ['upload'] if no page path is available. Returns empty array for non-FileUploadField components. + */ + getStateKeys(component: FormComponent): string[] { + // Only return upload keys for FileUploadField components + if (!(component instanceof FileUploadField)) { + return [] + } + + const pagePath = component.page?.path + return pagePath ? [`upload['${pagePath}']`] : ['upload'] + } + getFormDataFromState( request: FormContextRequest | undefined, state: FormSubmissionState diff --git a/src/server/plugins/engine/pageControllers/PageController.test.ts b/src/server/plugins/engine/pageControllers/PageController.test.ts index 73c7cb15..48093dc4 100644 --- a/src/server/plugins/engine/pageControllers/PageController.test.ts +++ b/src/server/plugins/engine/pageControllers/PageController.test.ts @@ -236,4 +236,11 @@ describe('PageController', () => { ) }) }) + + describe('getStateKeys', () => { + it('should return empty array by default', () => { + const stateKeys = controller1.getStateKeys() + expect(stateKeys).toEqual([]) + }) + }) }) diff --git a/src/server/plugins/engine/pageControllers/PageController.ts b/src/server/plugins/engine/pageControllers/PageController.ts index 3254ff31..1cdf48f2 100644 --- a/src/server/plugins/engine/pageControllers/PageController.ts +++ b/src/server/plugins/engine/pageControllers/PageController.ts @@ -9,6 +9,7 @@ import Boom from '@hapi/boom' import { type Lifecycle, type RouteOptions, type Server } from '@hapi/hapi' import { type ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { type FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { getSaveAndExitHelpers, getStartPath, @@ -175,6 +176,22 @@ export class PageController { throw Boom.badRequest('Unsupported POST route handler for this page') } + /** + * Get supplementary state keys for clearing component state. + * + * This method returns page controller-level state keys only. The core component's + * state key (the component's name) is managed separately by the framework and should + * NOT be included in the returned array. + * + * Returns an empty array by default. Override in subclasses to provide + * page-specific supplementary state keys (e.g., upload state, cached data). + * @param _component - The component to get supplementary state keys for (optional) + * @returns Array of supplementary state keys to clear (excluding the component name itself) + */ + getStateKeys(_component?: FormComponent): string[] { + return [] + } + shouldShowSaveAndExit(server: Server): boolean { return getSaveAndExitHelpers(server) !== undefined && this.allowSaveAndExit } diff --git a/src/server/plugins/engine/pageControllers/errors.test.ts b/src/server/plugins/engine/pageControllers/errors.test.ts index ac7c3910..f7470918 100644 --- a/src/server/plugins/engine/pageControllers/errors.test.ts +++ b/src/server/plugins/engine/pageControllers/errors.test.ts @@ -16,7 +16,7 @@ describe('InvalidComponentStateError', () => { }) describe('getStateKeys', () => { - it('should return component name and upload for FileUploadField', () => { + it('should return component name and nested upload path for FileUploadField', () => { const page = model.pages.find((p) => p.path === '/file-upload-component') const component = new FileUploadField( { @@ -35,7 +35,10 @@ describe('InvalidComponentStateError', () => { ) const stateKeys = error.getStateKeys() - expect(stateKeys).toEqual(['fileUpload', 'upload']) + expect(stateKeys).toEqual([ + 'fileUpload', + "upload['/file-upload-component']" + ]) }) it('should return only component name for non-FileUploadField components', () => { diff --git a/src/server/plugins/engine/pageControllers/errors.ts b/src/server/plugins/engine/pageControllers/errors.ts index b0361b18..c96fb76f 100644 --- a/src/server/plugins/engine/pageControllers/errors.ts +++ b/src/server/plugins/engine/pageControllers/errors.ts @@ -1,4 +1,3 @@ -import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js' import { type FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' /** @@ -23,7 +22,7 @@ export class InvalidComponentStateError extends Error { getStateKeys() { const extraStateKeys = - this.component instanceof FileUploadField ? ['upload'] : [] + this.component.page?.getStateKeys(this.component) ?? [] return [this.component.name].concat(extraStateKeys) } diff --git a/src/server/services/cacheService.ts b/src/server/services/cacheService.ts index 5a7be807..3db9b9e1 100644 --- a/src/server/services/cacheService.ts +++ b/src/server/services/cacheService.ts @@ -1,5 +1,6 @@ import { type Server } from '@hapi/hapi' import * as Hoek from '@hapi/hoek' +import unset from 'lodash/unset.js' import { config } from '~/src/config/index.js' import { type createServer } from '~/src/server/index.js' @@ -99,6 +100,29 @@ export class CacheService { request.yar.flash(key.id, message) } + /** + * Resets (removes) component states from the form state by their keys. + * Supports both flat keys and nested paths. + * @param request - The Hapi request object + * @param componentNames - Array of state keys to remove. Uses lodash's unset syntax. Can be: + * - Flat keys: `'componentName'` for top-level state + * - Nested paths: `"upload['/my-page']"` or `'upload./my-page'` for nested state + * @example + * ```typescript + * // Remove a flat component state + * await cacheService.resetComponentStates(request, ['emailAddress']) + * + * // Remove nested upload state for a specific page + * await cacheService.resetComponentStates(request, ["upload['/file-upload-page']"]) + * + * // Remove multiple states at once + * await cacheService.resetComponentStates(request, [ + * 'componentName', + * "upload['/my-page']" + * ]) + * ``` + * @returns The updated state after removal + */ async resetComponentStates( request: AnyFormRequest, componentNames: string[] @@ -106,10 +130,7 @@ export class CacheService { const state = await this.getState(request) for (const componentName of componentNames) { - if (componentName in state) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete state[componentName] - } + unset(state, componentName) } return this.setState(request, state)