Skip to content

Commit

Permalink
feature: mid journey exit (#1277)
Browse files Browse the repository at this point in the history
* add /{id}/exit/email and /{id}/exit/status routes

* add exit options definition

* add exitOptions typings

* add exitService

* add pageExitedOn and cacheService.setExitState

* inline documentation

* add documentation

* add ExitService tests

* tidy exit options

* tidy exit options

* update FormModel.exitOptions type

* tidying and adding inline docs

* add refactor notes

* clear user's state

* remove exit confirmation page configurations for now

*feat: allow safelist to be parsed as JSON array

* save exit response to user's state

* use updated state for exit response

* test(exit): add test for back link, add test for initialised session

* add additional documentation for exiting an initialised form

* add step for exit form

* add utility property "formPath" to exit request

* add formPath to exit request

* add form name to render context
  • Loading branch information
jenbutongit authored Aug 1, 2024
1 parent ee316e4 commit c25fed2
Show file tree
Hide file tree
Showing 32 changed files with 1,280 additions and 6 deletions.
161 changes: 161 additions & 0 deletions docs/runner/exit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Exiting

This feature allows the user to exit a form. Their data will be sent to the configured URL. It is not the Runner's
responsibility to persist this data.

You can use this feature alongside [session-initialisation.md](./session-initialisation.md) to allow users to "save and return".

If the `exitOptions` property is present on the form, the form will be considered as "allowing exits". You must remove this
to disable it.

If exits are allowed, on all question pages, a button "save and come back later" will be displayed below the continue button.
When the user selects this

1. They will be taken to a page /{id}/exit/email, where /{id} is the form's ID (i.e. the form's file name), which asks for the user's email address.
2. If the email address is valid, a POST request will be made to the configured URL with the user's data
3. If the POST request was successful (2xx) code, the user will be redirected to a page /{id}/exit/status
- If the persistence API returned with a `redirectUrl` in the response body, the user will be redirected to this URL
- If no redirectUrl was returned, the user will be shown a page detailing the email address it was sent to.
- If the persistence API returned with an `expiry` in the response body, the user will be shown a message detailing how long they have to return
4. The user's cache will then be cleared

## Configuration

The exit feature is configured in the form JSON on the `exitOptions` property.

```json5
{
pages: [],
lists: [],
// ..etc
exitOptions: {
url: "your-persistence-api:3005",
format: "WEBHOOK", // can be "WEBHOOK" or "STATE"
},
}
```

`exitOptions.format` can be `WEBHOOK` or `STATE`. This will control in which format the data is sent exitOptions.url.

## Data sent

Depending on which `exitOptions.format` is configured for the form, the format the users' answers are in will be different.

With both formats, the request body will always include `exitState` and `formPath`, which is the form's id.

```json5
{
exitState: {
exitEmailAddress: "[email protected]", // the email address the user entered on /{id}/exit/email
pageExitedOn: "/form-a/your-address", // the page the user chose to exit on
},
formPath: "/form-a",
//...
}
```

### ExitOptions.format - STATE

This mirrors the data in the user's state for the form that they chose to exit on. For example, if the user is filling out
two forms at once, /form-a and /form-b, if the user chose to exit on /form-a, only data from /form-a will be sent.

```json5
{
exitState: {
exitEmailAddress: "[email protected]",
pageExitedOn: "/test/how-many-people",
},
progress: ["/test/uk-passport", "/test/how-many-people"],
checkBeforeYouStart: { ukPassport: true },
applicantDetails: { numberOfApplicants: "1 or fewer" },
formPath: "/test",
}
```

This may be an easier format for the persistence API to parse, however you must convert this data back into the webhook.

In future, the initialise session and webhook output formats may support this "flatter" format for easier persistence.
Note that this data format does not include information like the page or component titles.

### ExitOptions.format - WEBHOOK

This mirrors the same format that data is sent when configuring a webhook output. You may just choose to store the data
as is, until the user decides to return. This makes it simpler calling [session-initialisation.md](./session-initialisation.md).

```json5
{
name: "Digital Form Builder - Runner test",
metadata: {},
questions: [
{
category: "checkBeforeYouStart",
question: "Do you have a UK passport?",
fields: [
{
key: "ukPassport",
title: "Do you have a UK passport?",
type: "list",
},
],
index: 0,
},
{
category: "applicantDetails",
question: "How many applicants are there?",
fields: [
{
key: "numberOfApplicants",
title: "How many applicants are there?",
type: "list",
answer: "1 or fewer",
},
],
index: 0,
},
],
exitState: {
exitEmailAddress: "[email protected]",
pageExitedOn: "/test/how-many-people",
},
formPath: "/test",
}
```

## Persistence API response

The persistence API (exitOptions.url) should return a 2xx status code if the data was successfully persisted. What
"successfully persisted" means depends on your implementation and requirements. This may mean successfully stored in your database,
or successfully inserted into a queue.

The response body must be JSON, and can some or none include the following properties:

- `redirectUrl` - a URL to redirect the user to after the data has been persisted. This can be used to redirect the user to a
"success" page, or to a "hub" or "account" page.
- The redirectUrl must be on the `safelist` (`SAFELIST` environment variable) to allow redirects to it. This is to protect the user from being redirected to an unknown URL.
if the URL is not on the safelist, the user will be shown the runner's success page.
- `expiry` - The ISO date-time string of when the user's data will be deleted. This will be parsed in the format d MMMM yyyy (e.g. 9 July 2024) and shown to the user.

```json5
{
redirectUrl: "https://your-service.service.gov.uk/success",
expiry: "2024-07-09T00:00:00Z",
}
```

The user will be shown a generic error page if the request failed to send, or the API responded with a non 2xx code.

## Initialising session and exiting

When initialising a session you can configure the `callbackUrl`. This will send data to a different URL from
what is configured in the form JSON's webhook output.

Currently, when exiting an initialised session, the data will be sent to the URL configured in `exitOptions.url`. It will not be sent to the `callbackUrl`.

If you need to initialise a session, allow a user to exit, and still be able to identify the user, you should initialise
the session with `metadata`. The metadata should include an identifier so your API can match and merge their data if required.

When exiting the form, the user's last known page is given to you in the `exitState` object in `pageExitedOn` property,
`pageExitedOn` is given in the format `/{formId}/{pagePath}`, e.g. `/test/uk-passport`. If you want to return the user back
to this page wth initialised sessions, you can use the `redirectPath` option in the session initialisation payload.
Note that in the POST `/session/{formId}` payload, the `redirectPath` is relative to the formId, so you must remove `/{formId}`
from `/test/uk-passport` so the user is redirected to the correct page, e.g. `/uk-passport`.
36 changes: 36 additions & 0 deletions e2e/cypress/e2e/runner/exit.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
Feature: Exit
As a user,
I want to be able to exit the service,
so that I can save my progress and return at a later date.

Background:
Given the form "exit-expiry" exists

Scenario: Service can be exited with date displayed
When I navigate to the "exit-expiry" form
Then I see "Save and come back later"
When I choose "lisbon"
And I select the button "Save and come back later"
And I enter "[email protected]" for "Enter your email address"
And I select the button "Save and exit"
Then I see "9 July 2024"
And I see "[email protected]"


Scenario: A user can start exiting, then go back to the form
When I navigate to the "exit-expiry" form
Then I see "Save and come back later"
When I choose "lisbon"
And I select the button "Save and come back later"
And I go back
Then I see "First page"

Scenario: An initialised session can be exited
Given the session is initialised for the exit form
When I go to the initialised session URL
And I select the button "Save and come back later"
And I enter "[email protected]" for "Enter your email address"
And I select the button "Save and exit"
Then I see "Your application to exit test has been saved"
# TODO: Mock the API in the e2e process so we can check for correct data sent.

36 changes: 36 additions & 0 deletions e2e/cypress/e2e/runner/exit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { When } from "@badeball/cypress-cucumber-preprocessor";

When("the session is initialised for the exit form", () => {
const url = `${Cypress.env("RUNNER_URL")}/session/exit-expiry`;
cy.request("POST", url, {
options: {
callbackUrl: "http://localhost",
redirectPath: "/second-page",
},
metadata: {
id: "abcdef",
},
questions: [
{
fields: [
{
key: "whichConsulate",
answer: "lisbon",
},
],
index: 0,
},
{
category: "yourDetails",
fields: [
{
key: "fullName",
answer: "first last",
},
],
},
],
}).then((res) => {
cy.wrap(res.body.token).as("token");
});
});
132 changes: 132 additions & 0 deletions e2e/cypress/fixtures/exit-expiry.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
{
"metadata": {
"caseType": "generic"
},
"startPage": "/first-page",
"pages": [
{
"title": "First page",
"path": "/first-page",
"components": [
{
"name": "whichConsulate",
"options": {},
"type": "RadiosField",
"title": "which consulate",
"list": "IpQxvK",
"schema": {}
}
],
"next": [
{
"path": "/second-page"
}
]
},
{
"section": "yourDetails",
"title": "Second page",
"path": "/second-page",
"components": [
{
"name": "fullName",
"title": "Your full name",
"options": {},
"type": "TextField",
"schema": {}
}
],
"next": [
{
"path": "/summary"
}
]
},
{
"title": "Summary",
"path": "/summary",
"controller": "./pages/summary.js",
"components": []
}
],
"specialPages": {},
"lists": [
{
"title": "which consulate",
"name": "IpQxvK",
"type": "string",
"items": [
{
"text": "lisbon",
"value": "lisbon"
},
{
"text": "portimao",
"value": "portimao"
}
]
}
],
"sections": [
{
"name": "yourDetails",
"title": "Your details"
}
],
"conditions": [
{
"displayName": "isLisbon",
"name": "isLisbon",
"value": {
"name": "isLisbon",
"conditions": [
{
"field": {
"name": "whichConsulate",
"type": "RadiosField",
"display": "which consulate"
},
"operator": "is",
"value": {
"type": "Value",
"value": "lisbon",
"display": "lisbon"
}
}
]
}
},
{
"displayName": "isPortimao",
"name": "isPortimao",
"value": {
"name": "isPortimao",
"conditions": [
{
"field": {
"name": "whichConsulate",
"type": "RadiosField",
"display": "which consulate"
},
"operator": "is",
"value": {
"type": "Value",
"value": "portimao",
"display": "portimao"
}
}
]
}
}
],
"fees": [],
"outputs": [],

"version": 2,
"skipSummary": false,
"exitOptions": {
"format": "WEBHOOK",
"url": "https://61bca17e-fe74-40e0-9c15-a901ad120eca.mock.pstmn.io/exit/expiry"
},
"name": "exit test"
}
7 changes: 7 additions & 0 deletions model/src/data-model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ export type FeeOptions = {
payApiKey?: string | MultipleApiKeys | undefined;
};

export type ExitOptions = {
url: string;
redirectUrl?: string;
format?: "STATE" | "WEBHOOK";
};

/**
* `FormDefinition` is a typescript representation of `Schema`
*/
Expand All @@ -187,4 +193,5 @@ export type FormDefinition = {
specialPages?: SpecialPages;
paymentReferenceFormat?: string;
feeOptions: FeeOptions;
exitOptions: ExitOptions;
};
Loading

0 comments on commit c25fed2

Please sign in to comment.