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
14 changes: 14 additions & 0 deletions src/server/forms/simple-form.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'])
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,4 +236,11 @@ describe('PageController', () => {
)
})
})

describe('getStateKeys', () => {
it('should return empty array by default', () => {
const stateKeys = controller1.getStateKeys()
expect(stateKeys).toEqual([])
})
})
})
17 changes: 17 additions & 0 deletions src/server/plugins/engine/pageControllers/PageController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
7 changes: 5 additions & 2 deletions src/server/plugins/engine/pageControllers/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand All @@ -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', () => {
Expand Down
3 changes: 1 addition & 2 deletions src/server/plugins/engine/pageControllers/errors.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -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)
}
Expand Down
29 changes: 25 additions & 4 deletions src/server/services/cacheService.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -99,17 +100,37 @@ 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[]
) {
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)
Expand Down
Loading