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
40 changes: 19 additions & 21 deletions docs/PLUGIN_OPTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ If provided, the `onRequest` plugin option will be invoked on each request to an

```ts
export type OnRequestCallback = (
request: FormRequest | FormRequestPayload,
request: AnyFormRequest,
params: FormParams,
definition: FormDefinition,
metadata: FormMetadata
Expand All @@ -106,32 +106,30 @@ await server.register({

## saveAndExit

The `saveAndExit` plugin option enables custom session handling to enable "Save and Exit" functionality. It consists of three optional functions:
The `saveAndExit` plugin option enables custom session handling to enable "Save and Exit" functionality. It is an optional route handler function that is called with the hapi request and response toolkit in addition to the last argument which is the [form context](./REQUEST_LIFECYCLE.md) of the current page from which the save and exit button was pressed:

- `keyGenerator` - Generates unique cache keys for session storage
- `sessionHydrator` - Retrieves saved session data from external sources
- `sessionPersister` - Stores session data to external systems
```ts
export type SaveAndExitHandler = (
request: FormRequestPayload,
h: FormResponseToolkit,
context: FormContext
) => ResponseObject
```

```js
await server.register({
plugin,
options: {
saveAndExit: {
keyGenerator: (request) => {
const { userId, applicationId } = fetchSubmissionAttributes(request)
return `${userId}:${applicationId}`
},

sessionHydrator: async (request) => {
// Fetch saved state from database/API
const savedState = await fetchUserSession(request)
return savedState || null
},

sessionPersister: async (state, request) => {
// Save state to database/API
await saveUserSession(state, request)
}
saveAndExit: (
request: FormRequestPayload,
h: FormResponseToolkit,
context: FormContext
) => {
const { params } = request
const { slug } = params

// Redirect user to custom page to handle saving
return h.redirect(`/custom-magic-link-save-and-exit/${slug}`)
}
}
})
Expand Down
154 changes: 40 additions & 114 deletions docs/features/code-based/SAVE_AND_EXIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,140 +8,62 @@ render_with_liquid: false

# Save and Exit

The forms engine supports save and exit capabilities through the `saveAndExit` plugin option. This feature enables advanced session handling for applications that need custom session storage, retrieval, and management beyond the default in-memory Redis cache.
The forms engine supports save and exit capabilities through the `saveAndExit` plugin option. This feature enables applications to support end users saving their current answers and returning to the form at a later date.

## Overview

- **Generate custom cache keys** for session storage, e.g. if you want to cache by user ID
- **Hydrate sessions** from external data sources (e.g. pre-filling a form when making a return journey)
- **Persist session data** to external systems for long-term storage (e.g. Saving data to return later)

Using the above, users can save their progress and continue filling out forms later, even across different devices or browser sessions.
It does this by displaying a secondary button on each question page when the feature is enabled. When the button is clicked the form is submitted in the usual way and once the page data is validated, the provided `saveAndExit` handler is called. This is a standard hapi route handler with an additional `FormContext` parameter passed that contains the [current state of the users progression through the form](../../REQUEST_LIFECYCLE.md).

> **Note:** it is your responsibility to ensure any state that exists outside of the form engine is captured upon persistence and available during hydration, e.g. file uploads via CDP.

## Configuration

The `saveAndExit` option is configured when registering the forms engine plugin:

```js
```ts
await server.register({
plugin: formsEnginePlugin,
plugin,
options: {
// ... other options
saveAndExit: {
keyGenerator: (request) => string,
sessionHydrator: (request) => Promise<FormSubmissionState | null>,
sessionPersister: (state, request) => Promise<void>
}
saveAndExit: (
request: FormRequestPayload,
h: FormResponseToolkit,
context: FormContext
): ResponseObject => {}
}
})
```

## Functions

### keyGenerator

**Type:** `(request: RequestType) => string`
It is down to you to provide the mechanism by which you want to store the users data and provide them a means by which they can return to it at a later data. The `saveAndExit` handler simply activates the additional button, gives you the hook point in to the framework and provides you the data you need to know where the user had progressed to.

Generates a cache key used to store and retrieve user session state.
One common approach is ask end users for their email and send them a "magic link" that they can use to return with 28 days.

```js
const keyGenerator = (request) => {
const { userId, businessId, grantId } = request.app.userContext
return `${userId}:${businessId}:${grantId}`
}
```

**Parameters:**

- `request` - The Hapi request object containing user context and form parameters

**Returns:** A string that uniquely identifies the user's session

### sessionHydrator

**Type:** `(request: RequestType) => Promise<FormSubmissionState | null>`

Called when no session state is found in Redis cache. This function should fetch saved state from an external source (e.g., database, API) and return it in the same structure expected by the form engine. This will generally be the same value as provided as `state` to the `sessionPersister` function, so a user can resume their session.

```js
const sessionHydrator = async (request) => {
const { userId, businessId, grantId } = request.app.userContext
const key = `${userId}:${businessId}:${grantId}`

try {
const response = await fetch(`https://backend.api/state/${key}`)
if (!response.ok) return null

const state = await response.json()
return state // Must match FormSubmissionState structure
} catch (error) {
request.logger.error('Failed to hydrate session', error)
return null
}
}
```

**Parameters:**

- `request` - The Hapi request object

**Returns:** Promise that resolves to either:

- `FormSubmissionState` object containing the user's saved form data
- `null` if no saved state is found or an error occurs

### sessionPersister

**Type:** `(state: FormSubmissionState, request: RequestType) => Promise<void>`

Called to persist session state to an external system for long-term storage.

```js
const sessionPersister = async (state, request) => {
const { userId, businessId, grantId } = request.app.userContext
const key = `${userId}:${businessId}:${grantId}`
try {
await fetch(`https://your-backend.api/state/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state)
})

request.logger.info(`Session persisted for key: ${key}`)
} catch (error) {
request.logger.error('Failed to persist session', error)
throw error
// This example shows how you can support custom UI flows to allow an end user to save their form progress and return at a later date.
// The save and exit method is called like other hapi route handlers and expects a similar return value.
// Here we're redirecting the user to another page where we might be providing a magic link or similar that the user can use to return to the form with.
await server.register({
plugin,
options: {
saveAndExit: (
request: FormRequestPayload,
h: FormResponseToolkit,
context: FormContext
) => {
const { params } = request
const { slug } = params
const usersAnswers = context.state

// Redirect user to custom page to handle saving
return h.redirect(`/custom-magic-link-save-and-exit/${slug}`)
}
}
}
})
```

**Parameters:**

- `state` - The current form submission state to be persisted
- `request` - The Hapi request object

**Returns:** Promise that resolves when the state is successfully persisted

## Session Flow

The session management system works as follows:

1. **Key Generation**: When a user accesses a form, `keyGenerator` creates a unique cache key
2. **Cache Check**: The engine checks the cache for existing session data
3. **Hydration**: If no data exists in the cache, `sessionHydrator` is called to fetch from external storage
4. **Restoration**: Retrieved data is loaded back into Redis for fast access during the session
5. **Persistence**: When users save their progress, `sessionPersister` stores data to external storage

Notes:

- The rehydrated state must include enough information to satisfy schema validation on the current or next page.
- To properly resume a session, users should be redirected to the `/summary` page. The form engine will detect if the session state is incomplete, then the user will be redirected back to the last valid page.

## Data Structure

The `FormSubmissionState` object passed to and from session management functions contains:
The `FormSubmissionState` object can be found at `context.state` and contains all the answers the user has provided so far.

This is the data you'll need to save to allow users to pick up from where they left.

```typescript
interface FormSubmissionState {
Expand All @@ -156,7 +78,11 @@ interface FormSubmissionState {
}
```

## Error Handling
## Restore session data

To restore a user's previous state use the `cacheService.setState` method.
The current request is passed in order to generate the cache key as so should include the correct form `slug` and `status` (if using the draft/live feature)

- `sessionHydrator` should return `null` if no saved state is found or if errors occur
- `sessionPersister` should throw errors if persistence fails
```js
await cacheService.setState(request, state)
```
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"./services/*": "./.server/server/plugins/engine/services/*",
"./engine/*": "./.server/server/plugins/engine/*",
"./helpers.js": "./.server/server/plugins/engine/components/helpers.js",
"./schema.js": "./.server/server/schemas/index.js",
"./templates/*": "./.server/server/plugins/engine/views/*",
"./package.json": "./package.json"
},
Expand Down
39 changes: 0 additions & 39 deletions src/server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,42 +639,3 @@ describe('prepareEnvironment', () => {
)
})
})

describe('Exit route handlers', () => {
let server: Server

beforeAll(async () => {
server = await createServer({
services: defaultServices
})
await server.initialize()
})

afterAll(async () => {
await server.stop()
})

beforeEach(() => {
jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata)
server.app.models.clear()
})

test('GET /exit returns 200 with exit page content', async () => {
jest.mocked(getFormMetadata).mockResolvedValueOnce({
...fixtures.form.metadata,
live: fixtures.form.state
})

jest.mocked(getFormDefinition).mockResolvedValue(fixtures.form.definition)

const options = {
method: 'GET',
url: `${FORM_PREFIX}/slug/exit`
}

const res = await server.inject(options)

expect(res.statusCode).toBe(StatusCodes.OK)
expect(res.result).toContain('Your progress has been saved')
})
})
5 changes: 3 additions & 2 deletions src/server/plugins/engine/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import {
import {
FormAction,
FormStatus,
type FormRequest
type FormRequest,
type FormResponseToolkit
} from '~/src/server/routes/types.js'
import definition from '~/test/form/definitions/basic.js'
import templateDefinition from '~/test/form/definitions/templates.js'
Expand All @@ -47,7 +48,7 @@ type HrefFilter = (this: NunjucksContext, path: string) => string | undefined
describe('Helpers', () => {
let page: PageControllerClass
let request: FormContextRequest
let h: Pick<ResponseToolkit, 'redirect' | 'view'>
let h: FormResponseToolkit

beforeEach(() => {
const model = new FormModel(definition, {
Expand Down
8 changes: 4 additions & 4 deletions src/server/plugins/engine/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
import {
type AnyFormRequest,
type FormContext,
type FormContextRequest,
type FormSubmissionError
Expand All @@ -32,8 +33,7 @@ import {
FormStatus,
type FormParams,
type FormQuery,
type FormRequest,
type FormRequestPayload
type FormResponseToolkit
} from '~/src/server/routes/types.js'

const logger = createLogger()
Expand Down Expand Up @@ -117,7 +117,7 @@ engine.registerFilter('answer', function (name: string) {

export function proceed(
request: Pick<FormContextRequest, 'method' | 'payload' | 'query'>,
h: Pick<ResponseToolkit, 'redirect' | 'view'>,
h: FormResponseToolkit,
nextUrl: string
) {
const { method, payload, query } = request
Expand Down Expand Up @@ -327,7 +327,7 @@ export function getError(detail: ValidationErrorItem): FormSubmissionError {
* is not disabled on the current route, and that cookies/state are present.
*/
export function safeGenerateCrumb(
request: FormRequest | FormRequestPayload | null
request: AnyFormRequest | null
): string | undefined {
// no request or no .state
if (!request?.state) {
Expand Down
6 changes: 1 addition & 5 deletions src/server/plugins/engine/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,7 @@ const pluginRegistrationOptionsSchema = Joi.object({
preparePageEventRequestOptions: Joi.function().optional(),
onRequest: Joi.function().optional(),
baseUrl: Joi.string().uri().required(),
saveAndExit: Joi.object({
keyGenerator: Joi.function(),
sessionHydrator: Joi.function(),
sessionPersister: Joi.function()
}).optional()
saveAndExit: Joi.function().optional()
})

/**
Expand Down
8 changes: 2 additions & 6 deletions src/server/plugins/engine/options.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe('validatePluginOptions', () => {
expect(validatePluginOptions(validOptions)).toEqual(validOptions)
})

it('accepts optional properties keyGenerator, sessionHydrator, and sessionPersister', () => {
it('accepts optional property saveAndExit', () => {
/**
* @type {PluginOptions}
*/
Expand All @@ -32,11 +32,7 @@ describe('validatePluginOptions', () => {
return { hello: 'world' }
},
baseUrl: 'http://localhost:3009',
saveAndExit: {
keyGenerator: () => 'test-key',
sessionHydrator: () => Promise.resolve({ someState: 'value' }),
sessionPersister: () => Promise.resolve(undefined)
}
saveAndExit: (request, h) => h.redirect('/save-and-exit')
}

expect(validatePluginOptions(validOptionsWithOptionals)).toEqual(
Expand Down
Loading
Loading