diff --git a/.eslintrc.json b/.eslintrc.json index 9cc11f6baa..8cf31a364f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,10 +1,7 @@ { - "extends": [ - "plugin:json/recommended", - "plugin:prettier/recommended" - ], + "extends": ["plugin:json/recommended", "plugin:prettier/recommended"], "plugins": ["@babel", "prettier"], - "parser": "babel-eslint", + "parser": "@babel/eslint-parser", "env": { "browser": true, "es6": true, @@ -38,7 +35,12 @@ { "files": ["*.ts", "*.tsx"], "parser": "@typescript-eslint/parser", - "plugins": ["@babel", "prettier", "@typescript-eslint", "eslint-plugin-tsdoc"], + "plugins": [ + "@babel", + "prettier", + "@typescript-eslint", + "eslint-plugin-tsdoc" + ], "parserOptions": { "ecmaFeatures": { "jsx": true } } diff --git a/.github/workflows/beta--lint-unit-build-and-publish-images.yml b/.github/workflows/beta--lint-unit-build-and-publish-images.yml new file mode 100644 index 0000000000..a033899b8b --- /dev/null +++ b/.github/workflows/beta--lint-unit-build-and-publish-images.yml @@ -0,0 +1,81 @@ +name: Beta QA +on: + push: + branches: + - beta + +jobs: + calculate-version: + runs-on: ubuntu-latest + outputs: + semVer: ${{ steps.gitversion.outputs.semVer }} + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + branches: main + + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v0.9.7 + with: + versionSpec: "5.x" + + - name: Determine Version + id: gitversion + uses: gittools/actions/gitversion/execute@v0.9.7 + with: + useConfigFile: true + + assign-semver: + runs-on: ubuntu-latest + needs: [calculate-version] + env: + SEMVER: ${{ needs.calculate-version.outputs.semVer }} + MAJOR: ${{ needs.calculate-version.outputs.Major }} + outputs: + SEMVER: ${{ steps.calc-semver.outputs.semver }} + steps: + - run: echo $SEMVER + - name: Add 3 to calculated semver + run: | + echo SEMVER="$((3 + MAJOR))${SEMVER:1}" >> $GITHUB_ENV + - name: Set semver to output + id: calc-semver + run: echo "::set-output name=semver::$(echo $SEMVER)" + + lint-and-test: + name: Workspace + strategy: + matrix: + workspace: [model, designer, runner, submitter] + uses: ./.github/workflows/lint-and-test.yml + with: + workspace: ${{ matrix.workspace }} + + build-and-publish-images: + name: Build and publish + needs: [calculate-version, assign-semver, lint-and-test] + strategy: + matrix: + app: [designer, runner, submitter] + uses: ./.github/workflows/build.yml + secrets: inherit + with: + semver: ${{ needs.assign-semver.outputs.SEMVER }} + publish: true + app: ${{matrix.app}} + + tag-branch: + runs-on: ubuntu-latest + needs: [calculate-version, assign-semver, build-and-publish-images] + env: + SEMVER: ${{ needs.assign-semver.outputs.SEMVER }} + steps: + - name: Checkout repo + uses: actions/checkout@v2 + with: + token: ${{ secrets.GHCR_PAT }} + - name: Tag branch with run number + run: | + git tag ${{ env.SEMVER }} + git push --tags origin HEAD diff --git a/.github/workflows/branch--lint-unit-and-smoke-test.yml b/.github/workflows/branch--lint-unit-and-smoke-test.yml index 03582521fe..cf5cff02cf 100644 --- a/.github/workflows/branch--lint-unit-and-smoke-test.yml +++ b/.github/workflows/branch--lint-unit-and-smoke-test.yml @@ -3,6 +3,7 @@ on: pull_request: branches: - main + - beta paths-ignore: - "docs/**" - "**/README.md" @@ -18,7 +19,7 @@ jobs: name: Workspace strategy: matrix: - workspace: [model, designer, runner] + workspace: [model, designer, runner, submitter] uses: ./.github/workflows/lint-and-test.yml with: workspace: ${{ matrix.workspace }} @@ -30,6 +31,13 @@ jobs: app: designer secrets: inherit + build-submitter: + name: Submitter + uses: ./.github/workflows/build.yml + with: + app: submitter + secrets: inherit + build-runner: name: Runner uses: ./.github/workflows/build.yml @@ -38,7 +46,7 @@ jobs: secrets: inherit smoke-test: - needs: [build-runner,build-designer] + needs: [build-runner, build-designer] uses: ./.github/workflows/smoke-test.yml with: runner-cache-ref: ${{needs.build-runner.outputs.tag}} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b8cc263fb0..bc17d1a126 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ on: workflow_call: inputs: app: - description: the app to build "designer" or "runner" + description: the app to build "designer", "runner" or "submitter" required: true type: string publish: @@ -23,7 +23,6 @@ on: description: hash used for docker caching value: ${{ jobs.build-app.outputs.hash }} - jobs: build-app: if: ${{!contains(github.event.head_commit.message, 'chore(deps-dev)')}} @@ -33,12 +32,11 @@ jobs: tag: ${{ steps.hashFile.outputs.tag }} hash: ${{ steps.hashFile.outputs.hash }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v3.6.0 - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v3.6.0 with: node-version: "16.x" - cache: yarn - name: Get yarn cache directory path id: yarn-cache-dir-path @@ -51,6 +49,7 @@ jobs: key: ${{ runner.os }}-yarn-${{inputs.app}}-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-yarn-${{inputs.app}} + fail-on-cache-miss: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 @@ -65,7 +64,6 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # The hash is a function of the Dockerfile and the yarn.lock Packages take up the bulk of the time during a docker build. The hash is used to cache the docker builds. # As long as the yarn.lock and dockerfiles are the same, you will be able to share this cache with another commit/branch. - id: hashFile diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index fe461b4243..3bd12c66a1 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -4,7 +4,7 @@ # # Source repository: https://github.com/actions/dependency-review-action # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement -name: 'Dependency Review' +name: "Dependency Review" on: [pull_request] permissions: @@ -14,7 +14,11 @@ jobs: dependency-review: runs-on: ubuntu-latest steps: - - name: 'Checkout Repository' + - name: "Checkout Repository" uses: actions/checkout@v3 - - name: 'Dependency Review' + - name: "Dependency Review" uses: actions/dependency-review-action@v2 + with: + allow-ghsas: + - GHSA-c429-5p7v-vgjp + - GHSA-7fh5-64p2-3v2j diff --git a/.github/workflows/main--lint-unit-build-and-publish-images.yml b/.github/workflows/main--lint-unit-build-and-publish-images.yml index 375b33ab20..b50fe9b0e4 100644 --- a/.github/workflows/main--lint-unit-build-and-publish-images.yml +++ b/.github/workflows/main--lint-unit-build-and-publish-images.yml @@ -47,7 +47,7 @@ jobs: name: Workspace strategy: matrix: - workspace: [ model, designer, runner ] + workspace: [model, designer, runner, submitter] uses: ./.github/workflows/lint-and-test.yml with: workspace: ${{ matrix.workspace }} @@ -57,7 +57,7 @@ jobs: needs: [calculate-version, assign-semver, lint-and-test] strategy: matrix: - app: [designer, runner] + app: [designer, runner, submitter] uses: ./.github/workflows/build.yml secrets: inherit with: @@ -71,7 +71,7 @@ jobs: secrets: inherit strategy: matrix: - app: [ designer, runner ] + app: [designer, runner] with: app: ${{ matrix.app }} tag: ${{ needs.assign-semver.outputs.SEMVER }} diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index ff465cdca8..4341901a1e 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -30,7 +30,6 @@ jobs: id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn config get cacheFolder)" - - uses: actions/cache@v3 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: @@ -53,7 +52,6 @@ jobs: config-inline: | [registry."ghcr.io"] - - name: Login to GitHub Container Registry uses: docker/login-action@v2 with: @@ -88,5 +86,9 @@ jobs: docker compose -f docker-compose.smoke.yml up -d docker ps + - name: log + run: | + docker compose logs runner + - name: run smoke tests run: yarn e2e cypress run diff --git a/.gitignore b/.gitignore index 22413ef5b0..25abc0b60a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,8 @@ tsconfig.tsbuildinfo docs/**/typedoc /e2e/cypress/screenshots/ -/runner/config/default.js +.env_mysql +/queue-model/dist +/queue-model/module +/queue-model/src/prisma/generated/runner/config/default.js runner/config/default.js diff --git a/README.md b/README.md index ebf4d83400..13b01f0b9a 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Also see the individual repo README files for additional info: **Always run scripts from the root directory.** -1. Make sure you are using node 16 `node --version`. +1. Make sure you are using node 18 `node --version`. 2. Make sure you have yarn 1.22+ installed. You do not need to install yarn 2.4+, yarn will detect the yarn 2 binary within [.yarn](./.yarn) and that will be used. 3. If using the designer: - Note that the designer requires the runner to be running with the default `NODE_ENV=development` settings (see [runner/config/development.json](https://github.com/XGovFormBuilder/digital-form-builder/tree/main/runner/config/development.json)) to enable posting and previewing of forms during design. @@ -116,6 +116,6 @@ The latest releases will be running here: [Runner](https://digital-form-builder- A suite of smoke tests are run against all PRs. There is a Cron Job that executes smoke tests against the Heroku deployments and is scheduled to run at midnight every day. -A legacy suite of smoke tests can be found in this [repository](https://github.com/XGovFormBuilder/digital-form-builder-legacy-smoke-tests). They have been removed so that the project can run on node 16. +A legacy suite of smoke tests can be found in this [repository](https://github.com/XGovFormBuilder/digital-form-builder-legacy-smoke-tests). They have been removed so that the project can run on node 18. Smoke tests will be migrated to use [cypress.io](https://cypress.io) in the coming months. diff --git a/babel.config.json b/babel.config.json index 81698c3e65..0cbf17ce9e 100644 --- a/babel.config.json +++ b/babel.config.json @@ -3,11 +3,11 @@ "exclude": ["node_modules/**"], "plugins": [ "@babel/plugin-proposal-export-default-from", - "@babel/plugin-proposal-class-properties", - "@babel/plugin-proposal-private-property-in-object", - "@babel/plugin-proposal-private-methods", + "@babel/plugin-transform-class-properties", + "@babel/plugin-transform-private-property-in-object", + "@babel/plugin-transform-private-methods", "@babel/plugin-transform-runtime", "@babel/plugin-syntax-jsx", - "@babel/plugin-proposal-logical-assignment-operators" + "@babel/plugin-transform-logical-assignment-operators" ] } diff --git a/designer/Dockerfile b/designer/Dockerfile index dac1f04e2a..a1938c2fa5 100644 --- a/designer/Dockerfile +++ b/designer/Dockerfile @@ -2,7 +2,7 @@ # Stage 1 # Base image contains the updated OS and # It also configures the non-root user that will be given permission to copied files/folders in every subsequent stages -FROM node:16-alpine AS base +FROM node:18-alpine AS base RUN mkdir -p /usr/src/app && \ addgroup -g 1001 appuser && \ adduser -S -u 1001 -G appuser appuser && \ @@ -22,6 +22,7 @@ COPY --chown=appuser:appuser package.json yarn.lock .yarnrc.yml tsconfig.json . COPY --chown=appuser:appuser model/package.json model/package.json COPY --chown=appuser:appuser runner/package.json runner/package.json COPY --chown=appuser:appuser designer/package.json designer/package.json +COPY --chown=appuser:appuser queue-model/package.json queue-model/package.json USER 1001 RUN --mount=type=cache,target=.yarn/cache,id=base,uid=1001,mode=0755 yarn @@ -51,6 +52,8 @@ ARG LAST_COMMIT="NOT_DEFINED" ARG LAST_TAG="NOT_DEFINED" ENV LAST_COMMIT=$LAST_COMMIT ENV LAST_TAG=$LAST_TAG +ENV NODE_OPTIONS="--openssl-legacy-provider" +COPY --chown=appuser:appuser designer/package.json ./designer/ RUN --mount=type=cache,target=.yarn/cache,id=designer,uid=1001,mode=0755 yarn workspaces focus @xgovformbuilder/designer COPY --chown=appuser:appuser ./designer ./designer/ diff --git a/designer/babel.config.js b/designer/babel.config.js index 50d6e7915c..83080b8780 100644 --- a/designer/babel.config.js +++ b/designer/babel.config.js @@ -20,15 +20,14 @@ module.exports = { ], ], plugins: [ - "@babel/plugin-proposal-class-properties", - "@babel/plugin-proposal-private-methods", + "@babel/plugin-transform-class-properties", + "@babel/plugin-transform-private-methods", "@babel/plugin-syntax-dynamic-import", "@babel/plugin-transform-runtime", "@babel/plugin-proposal-export-default-from", - "@babel/plugin-proposal-nullish-coalescing-operator", - "@babel/plugin-proposal-optional-chaining", - "@babel/plugin-proposal-logical-assignment-operators", - + "@babel/plugin-transform-nullish-coalescing-operator", + "@babel/plugin-transform-logical-assignment-operators", + "@babel/plugin-transform-optional-chaining", [ "module-resolver", { diff --git a/designer/client/ComponentTypeEdit.tsx b/designer/client/ComponentTypeEdit.tsx index a06c2168c1..9c464a2b85 100644 --- a/designer/client/ComponentTypeEdit.tsx +++ b/designer/client/ComponentTypeEdit.tsx @@ -51,7 +51,10 @@ function ComponentTypeEdit(props) { return (
{needsFieldInputs && ( - + )} {TagName && }
diff --git a/designer/client/__mocks__/tabbable.js b/designer/client/__mocks__/tabbable.js new file mode 100644 index 0000000000..3ddff586f7 --- /dev/null +++ b/designer/client/__mocks__/tabbable.js @@ -0,0 +1,14 @@ +const lib = jest.requireActual("tabbable"); +const tabbable = { + ...lib, + tabbable: (node, options) => + lib.tabbable(node, { ...options, displayCheck: "none" }), + focusable: (node, options) => + lib.focusable(node, { ...options, displayCheck: "none" }), + isFocusable: (node, options) => + lib.isFocusable(node, { ...options, displayCheck: "none" }), + isTabbable: (node, options) => + lib.isTabbable(node, { ...options, displayCheck: "none" }), +}; + +module.exports = tabbable; diff --git a/designer/client/components/Flyout/Flyout.tsx b/designer/client/components/Flyout/Flyout.tsx index 23e3aa9295..f3617623ff 100644 --- a/designer/client/components/Flyout/Flyout.tsx +++ b/designer/client/components/Flyout/Flyout.tsx @@ -1,16 +1,32 @@ -import React, { useContext, useEffect, useLayoutEffect, useState } from "react"; +import React, { + CSSProperties, + ReactChildren, + useContext, + useLayoutEffect, + useState, +} from "react"; import FocusTrap from "focus-trap-react"; import { FlyoutContext } from "../../context"; -import { DataContext } from "../../context"; import { i18n } from "../../i18n"; import "./Flyout.scss"; -import { bool } from "aws-sdk/clients/signer"; -export function useFlyoutEffect(props: {}) { +interface Props { + style: string; + width?: string; + onHide: () => void; + closeOnEnter: (e) => void; + show: boolean; + offset: number; + title?: string; + children?: ReactChildren; + NEVER_UNMOUNTS?: boolean; +} + +export function useFlyoutEffect(props: Props) { const flyoutContext = useContext(FlyoutContext); const [offset, setOffset] = useState(0); - const [style, setStyle] = useState(); + const [style, setStyle] = useState(); const show = props.show ?? true; /** @@ -58,7 +74,7 @@ export function useFlyoutEffect(props: {}) { return { style, width: props?.width, closeOnEnter, onHide, offset, show }; } -export function Flyout(props) { +export function Flyout(props: Props) { const { style, width = "", diff --git a/designer/client/components/Flyout/__tests__/Flyout.test.tsx b/designer/client/components/Flyout/__tests__/Flyout.test.tsx index 476fee2557..d0bf7fbbe5 100644 --- a/designer/client/components/Flyout/__tests__/Flyout.test.tsx +++ b/designer/client/components/Flyout/__tests__/Flyout.test.tsx @@ -14,6 +14,7 @@ const { test, describe, beforeEach, afterEach } = lab; function HookWrapper(props) { const hook = props.hook ? props.hook() : undefined; // @ts-ignore + // eslint-disable-next-line react/no-unknown-property return
; } diff --git a/designer/client/conditions/InlineConditions.tsx b/designer/client/conditions/InlineConditions.tsx index 6aeb7d050b..7aa75cbcc6 100644 --- a/designer/client/conditions/InlineConditions.tsx +++ b/designer/client/conditions/InlineConditions.tsx @@ -13,6 +13,7 @@ import { allInputs, findList, inputsAccessibleAt, + removeCondition, updateCondition, } from "../data"; import randomId from "../randomId"; @@ -147,8 +148,6 @@ export class InlineConditions extends React.Component { return; } - const copy = { ...data }; - if (condition) { const updatedData = updateCondition(data, condition.name, conditions); await save(updatedData); @@ -169,6 +168,18 @@ export class InlineConditions extends React.Component { } }; + onClickDelete = async (event: MouseEvent) => { + event?.preventDefault(); + const { data, save } = this.context; + const { cancelCallback, condition } = this.props; + + const updatedData = removeCondition(data, condition.name); + await save(updatedData); + if (cancelCallback) { + cancelCallback(event); + } + }; + saveCondition = (condition) => { this.setState({ conditions: this.state.conditions.add(condition), @@ -314,16 +325,28 @@ export class InlineConditions extends React.Component { fields={this.state.fields} saveCallback={this.saveCondition} /> -
+
{hasConditions && ( - - {i18n("save")} - + <> + + {i18n("save")} + + {this.props.condition && ( + + {i18n("delete")} + + )} + )} { @@ -81,22 +91,25 @@ class SelectConditions extends React.Component { const { data } = this.context; const fields: any = Object.values(this.fieldsForPath(path)); const { conditions = [] } = data; - var conditionsForPath: any[] = []; - - const stringConditions = conditions.filter( - (condition) => typeof condition.value === "string" - ); - const objectConditions = conditions.filter( - (condition) => typeof condition.value !== "string" - ); + let conditionsForPath: any[] = []; + const conditionsByTypeMap = conditionsByType(conditions); fields.forEach((field) => { this.handleStringConditions( - stringConditions, + conditionsByTypeMap.string, + field.name, + conditionsForPath + ); + this.handleConditions( + conditionsByTypeMap.object, + field.name, + conditionsForPath + ); + this.handleNestedConditions( + conditionsByTypeMap.nested, field.name, conditionsForPath ); - this.handleConditions(objectConditions, field.name, conditionsForPath); }); return conditionsForPath; @@ -108,11 +121,11 @@ class SelectConditions extends React.Component { conditionsForPath: any[] ) { objectConditions.forEach((condition) => { - condition.value.conditions.forEach((innerCondition) => { + condition.value.conditions?.forEach((innerCondition) => { this.checkAndAddCondition( condition, fieldName, - innerCondition.field.name, + getFieldNameSubstring(innerCondition.field.name), conditionsForPath ); }); @@ -150,7 +163,36 @@ class SelectConditions extends React.Component { conditionsForPath ) ); - var a = ""; + } + // loops through nested conditions, checking the referenced condition against the current field + handleNestedConditions( + nestedConditions: ConditionData[], + fieldName: string, + conditionsForPath: any[] + ) { + nestedConditions.forEach((condition) => { + condition.value.conditions.forEach((innerCondition) => { + // if the condition is already in the conditions array, skip the for each loop iteration + if (isDuplicateCondition(conditionsForPath, condition.name)) return; + // if the inner condition isn't a nested condition, handle it in the standard way + if (!hasConditionName(innerCondition)) { + this.checkAndAddCondition( + condition, + fieldName, + getFieldNameSubstring(innerCondition.field.name), + conditionsForPath + ); + return; + } + //if the inner condition is a nested condition, + //check if that nested condition is already in the conditions array, + //and if so, add this condition to the array + if ( + isDuplicateCondition(conditionsForPath, innerCondition.conditionName) + ) + conditionsForPath.push(condition); + }); + }); } checkAndAddCondition( @@ -159,10 +201,7 @@ class SelectConditions extends React.Component { conditionFieldName: string, conditions: any[] ) { - const isDuplicateCondition = conditions.includes( - (condition) => condition.name === conditionToAdd.name - ); - if (isDuplicateCondition) return; + if (isDuplicateCondition(conditions, conditionToAdd.name)) return; if (fieldName === conditionFieldName) conditions.push(conditionToAdd); } diff --git a/designer/client/conditions/__tests__/conditionsByType.jest.ts b/designer/client/conditions/__tests__/conditionsByType.jest.ts new file mode 100644 index 0000000000..6a1f77f2ec --- /dev/null +++ b/designer/client/conditions/__tests__/conditionsByType.jest.ts @@ -0,0 +1,55 @@ +import { conditionsByType } from "./../select-condition-helpers"; + +const stringCondition = { + name: "likesScrambledEggs", + displayName: "Likes scrambled eggs", + value: "likeScrambled == true", +}; + +const objectCondition = { + name: "likesFriedEggsCond", + displayName: "Likes fried eggs", + value: { + name: "likesFriedEggs", + conditions: [ + { + value: { + name: "likesFried", + displayName: "Do you like fried eggs?", + field: { + name: "likesFried", + type: "string", + display: "Do you like fried eggs?", + }, + operator: "is", + type: "Value", + value: "true", + display: "true", + }, + }, + ], + }, +}; + +const nestedCondition = { + name: "likesFriedAndScrambledEggs", + displayName: "Favourite egg is fried and scrambled", + value: { + conditions: [ + { + conditionName: "likesScrambledEggs", + conditionDisplayName: "likes scrambled eggs", + }, + { coordinator: "and", ...objectCondition }, + ], + }, +}; + +test("conditionsByType", () => { + const conditions = [stringCondition, objectCondition, nestedCondition]; + expect(conditionsByType(conditions)).toEqual({ + string: [stringCondition], + nested: [nestedCondition], + object: [objectCondition], + }); +}); diff --git a/designer/client/conditions/select-condition-helpers.ts b/designer/client/conditions/select-condition-helpers.ts new file mode 100644 index 0000000000..b42f368dd1 --- /dev/null +++ b/designer/client/conditions/select-condition-helpers.ts @@ -0,0 +1,57 @@ +import { ConditionData } from "./SelectConditions"; + +export const isObjectCondition = (condition: ConditionData) => { + return typeof condition.value !== "string"; +}; + +export const isStringCondition = (condition: ConditionData) => { + return typeof condition.value === "string"; +}; + +export const hasConditionName = (condition: any) => { + return !!condition?.conditionName; +}; + +export const hasNestedCondition = (condition: ConditionData) => { + if (typeof condition.value === "string") { + return false; + } + return condition.value.conditions?.find?.(hasConditionName) ?? false; +}; + +export const isDuplicateCondition = ( + conditions: any[], + conditionName: string +) => { + return !!conditions.find((condition) => condition.name === conditionName); +}; + +export const getFieldNameSubstring = (sectionFieldName: string) => { + return sectionFieldName.substring(sectionFieldName.indexOf(".")); +}; + +export function conditionsByType(conditions: ConditionData[]) { + return conditions.reduce( + (conditionsByType, currentValue) => { + if (isStringCondition(currentValue)) { + conditionsByType.string.push(currentValue); + } else if (!!hasNestedCondition(currentValue)) { + conditionsByType.nested.push(currentValue); + } else if (isObjectCondition(currentValue)) { + conditionsByType.object.push(currentValue); + } + return conditionsByType; + }, + { + string: [], + nested: [], + object: [], + } as ConditionByTypeMap + ); +} + +type ConditionByTypeMap = { + string: ConditionData[]; + nested: ConditionData[]; + object: ConditionData[]; +}; diff --git a/designer/client/data/types.ts b/designer/client/data/types.ts index d801378f4a..776d374eb8 100644 --- a/designer/client/data/types.ts +++ b/designer/client/data/types.ts @@ -16,6 +16,8 @@ export const isNotContentType = ( "Details", "Html", "InsetText", + "List", + "FlashCard", ]; return !contentTypes.find((type) => `${type}` === `${obj.type}`); }; diff --git a/designer/client/field-edit.tsx b/designer/client/field-edit.tsx index 9fc18182b2..bded117a09 100644 --- a/designer/client/field-edit.tsx +++ b/designer/client/field-edit.tsx @@ -9,19 +9,30 @@ import { ErrorMessage } from "./components/ErrorMessage"; type Props = { isContentField?: boolean; + isListField?: boolean; }; -export function FieldEdit({ isContentField = false }: Props) { +export function FieldEdit({ + isContentField = false, + isListField = false, +}: Props) { const { state, dispatch } = useContext(ComponentContext); const { selectedComponent, errors } = state; const { name, title, hint, attrs, type, options = {} } = selectedComponent; - const { hideTitle = false, optionalText = false, required = true } = options; + const { + hideTitle = false, + optionalText = false, + required = true, + exposeToContext = false, + allowPrePopulation = false, + allowPrePopulationOverwrite = false, + disableChangingFromSummary = false, + } = options; const isFileUploadField = selectedComponent.type === "FileUploadField"; const fieldTitle = ComponentTypes.find((componentType) => componentType.name === type) ?.title ?? ""; - return (
@@ -188,8 +199,134 @@ export function FieldEdit({ isContentField = false }: Props) {
+
+
+ + dispatch({ + type: Actions.EDIT_OPTIONS_EXPOSE_TO_CONTEXT, + payload: e.target.checked, + }) + } + /> + + + {i18n("common.exposeToContextOption.helpText")} + +
+
+ {isListField && ( + <> +
+
+ + dispatch({ + type: Actions.EDIT_OPTIONS_ALLOW_PRE_POPULATION, + payload: e.target.checked, + }) + } + /> + + + {i18n("common.allowPrePopulationOption.helpText")} + +
+
+
+
+ + dispatch({ + type: + Actions.EDIT_OPTIONS_ALLOW_OVERWRITE_FROM_QUERY_PARAM, + payload: e.target.checked, + }) + } + /> + + + {i18n("common.allowPrePopulationOverwriteOption.helpText")} + +
+
+ + )} +
+
+ + dispatch({ + type: Actions.EDIT_OPTIONS_DISABLE_CHANGING_FROM_SUMMARY, + payload: e.target.checked, + }) + } + /> + + + {i18n("common.disableChangingFromSummaryOption.helpText")} + +
+
); } + export default FieldEdit; diff --git a/designer/client/file-upload-field-edit.tsx b/designer/client/file-upload-field-edit.tsx index 7be3f57af3..bd64e80957 100644 --- a/designer/client/file-upload-field-edit.tsx +++ b/designer/client/file-upload-field-edit.tsx @@ -5,10 +5,15 @@ import { Actions } from "./reducers/component/types"; import { CssClasses } from "./components/CssClasses"; import { i18n } from "./i18n"; +const defaultOptions = { + multiple: false, + imageQualityPlayback: false, +}; + export function FileUploadFieldEdit() { const { state, dispatch } = useContext(ComponentContext); const { selectedComponent } = state; - const { options = {} } = selectedComponent; + const options = { ...defaultOptions, ...selectedComponent.options }; return (
@@ -22,21 +27,20 @@ export function FileUploadFieldEdit() {
{ - e.preventDefault(); dispatch({ type: Actions.EDIT_OPTIONS_FILE_UPLOAD_MULTIPLE, - payload: !options.multiple, + payload: e.target.checked, }); }} /> @@ -46,6 +50,35 @@ export function FileUploadFieldEdit() {
+
+
+ { + dispatch({ + type: Actions.EDIT_OPTIONS_IMAGE_QUALITY_PLAYBACK, + payload: e.target.checked, + }); + }} + /> + + + {i18n( + "fileUploadFieldEditPage.imageQualityPlaybackOption.helpText" + )} + +
+
+ ); diff --git a/designer/client/i18n/translations/en.translation.json b/designer/client/i18n/translations/en.translation.json index b5eac278e1..d6a58fe2ce 100644 --- a/designer/client/i18n/translations/en.translation.json +++ b/designer/client/i18n/translations/en.translation.json @@ -69,6 +69,22 @@ "detailsLink": { "title": "Additional settings" }, + "exposeToContextOption": { + "helpText": "If using additional context, choose this option to expose the field to be used by additional contexts", + "title": "Expose to context" + }, + "allowPrePopulationOption": { + "helpText": "Tick this box if you want this field to be available to be pre-populated by query parameter", + "title": "Allow pre-population" + }, + "allowPrePopulationOverwriteOption": { + "helpText": "Tick this box if you want query parameters to override state values for this field when passed in. This will only have an effect if 'Allow pre-population' is also ticked", + "title": "Allow overwriting from query parameter" + }, + "disableChangingFromSummaryOption": { + "helpText": "Tick this box if you want the change button to be removed for this field on the summary screen", + "title": "Disable changing from summary" + }, "helpTextField": { "helpText": "Enter the description to show for this field", "title": "Help text (optional)" @@ -204,6 +220,10 @@ "multipleFilesOption": { "helpText": "Tick this box to enable users to upload multiple files", "title": "Allow multiple file upload" + }, + "imageQualityPlaybackOption": { + "helpText": "If your document upload API assesses the quality of uploaded images, and you want to inform the user but don't want to stop them from continuing, check this box.", + "title": "Enable playback page for image quality checking" } }, "formDetails": { @@ -326,7 +346,9 @@ "outputEdit": { "notifyEdit": { "includeReferenceTitle": "Include webhook and payment references", - "includeReferenceHint": "If webhook or payment references are available, they will be included in Notify's personalisation object. The included fields are: hasWebhookReference (boolean), webhookReference: (string), hasPaymentReference: (boolean), paymentReference: string." + "includeReferenceHint": "If webhook or payment references are available, they will be included in Notify's personalisation object. The included fields are: hasWebhookReference (boolean), webhookReference: (string), hasPaymentReference: (boolean), paymentReference: string.", + "escapeURLsTitle": "Escape Notify encoded URLs", + "escapeURLsHint": "If you do not expect notify-encoded links to be passed through from your form, select this box to break this formatting. It is strongly recommended to enable this to prevent phishing attacks" } }, "page": { @@ -390,6 +412,10 @@ "titleField": { "helpText": "Appears above the page title. However, if these titles are the same, the form will only show the page title.", "title": "Section title" + }, + "hideTitleField": { + "helpText": "Tick this box if you do not want the section title to show on any pages in this section", + "title": "Hide section title" } }, "title": "Title", diff --git a/designer/client/list/ListEdit.tsx b/designer/client/list/ListEdit.tsx index 27de722ff2..5a9272a1b2 100644 --- a/designer/client/list/ListEdit.tsx +++ b/designer/client/list/ListEdit.tsx @@ -151,7 +151,12 @@ export function ListEdit() { {i18n("list.item.add")}
<> -
-
diff --git a/designer/client/outputs/__tests__/output-edit.jest.tsx b/designer/client/outputs/__tests__/output-edit.jest.tsx index 4fdecbfe7c..f0def08c7a 100644 --- a/designer/client/outputs/__tests__/output-edit.jest.tsx +++ b/designer/client/outputs/__tests__/output-edit.jest.tsx @@ -29,6 +29,7 @@ describe("OutputEdit", () => { ], outputs: [], conditions: [], + lists: [], }; }); @@ -103,6 +104,7 @@ describe("OutputEdit", () => { templateId: "NewTemplateId", apiKey: "NewAPIKey", emailField: "9WH4EX", + escapeURLs: false, addReferencesToPersonalisation: true, }, }, diff --git a/designer/client/outputs/notify-edit-items.tsx b/designer/client/outputs/notify-edit-items.tsx index a8f0308573..311c52802b 100644 --- a/designer/client/outputs/notify-edit-items.tsx +++ b/designer/client/outputs/notify-edit-items.tsx @@ -99,7 +99,12 @@ class NotifyItems extends React.Component { Notify template key -
+ Add diff --git a/designer/client/outputs/notify-edit.tsx b/designer/client/outputs/notify-edit.tsx index 8fae58414c..2ad372f3bd 100644 --- a/designer/client/outputs/notify-edit.tsx +++ b/designer/client/outputs/notify-edit.tsx @@ -33,7 +33,7 @@ class NotifyEdit extends Component { render() { const { data, output, onEdit, errors } = this.props; - const { conditions } = data; + const { conditions, lists } = data; const outputConfiguration = (typeof output.outputConfiguration === "object" ? output.outputConfiguration : { @@ -50,6 +50,10 @@ class NotifyEdit extends Component { name: condition.name, display: condition.displayName, })), + ...lists.map((list) => ({ + name: list.name, + display: `${list.title} (List)`, + })), ...this.usableKeys, ]; @@ -135,6 +139,24 @@ class NotifyEdit extends Component { name="add-references-to-personalisation" /> +
+ + {i18n("outputEdit.notifyEdit.escapeURLsTitle")} + + ), + hint: { + children: i18n("outputEdit.notifyEdit.escapeURLsHint"), + }, + value: true, + }, + ]} + name="escape-urls" + /> +
); } diff --git a/designer/client/outputs/output-edit.tsx b/designer/client/outputs/output-edit.tsx index 0e633d430f..80314f7c2b 100644 --- a/designer/client/outputs/output-edit.tsx +++ b/designer/client/outputs/output-edit.tsx @@ -83,11 +83,13 @@ class OutputEdit extends Component { emailField: formData.get("email-field") as string, addReferencesToPersonalisation: formData.get("add-references-to-personalisation") === "true", + escapeURLs: formData.get("escape-urls") === "true", }; break; case OutputType.Webhook: outputConfiguration = { url: formData.get("webhook-url") as string, + allowRetry: true, }; break; } diff --git a/designer/client/outputs/outputs-edit.tsx b/designer/client/outputs/outputs-edit.tsx index 361a96348d..f9799063a1 100644 --- a/designer/client/outputs/outputs-edit.tsx +++ b/designer/client/outputs/outputs-edit.tsx @@ -68,7 +68,11 @@ class OutputsEdit extends React.Component { ))}

  • - + Add output
  • diff --git a/designer/client/outputs/types.ts b/designer/client/outputs/types.ts index 6e2f9f60c0..241000da52 100644 --- a/designer/client/outputs/types.ts +++ b/designer/client/outputs/types.ts @@ -14,10 +14,12 @@ export type NotifyOutputConfiguration = { emailField: string; personalisation: string[]; addReferencesToPersonalisation?: boolean; + escapeURLs?: boolean; }; export type WebhookOutputConfiguration = { url: string; + allowRetry: boolean; }; export type OutputConfiguration = diff --git a/designer/client/reducers/component/componentReducer.options.ts b/designer/client/reducers/component/componentReducer.options.ts index c09d350056..ff346fadd9 100644 --- a/designer/client/reducers/component/componentReducer.options.ts +++ b/designer/client/reducers/component/componentReducer.options.ts @@ -53,6 +53,13 @@ export function optionsReducer(state, action: OptionsActions) { options: { ...options, multiple: payload }, }, }; + case Options.EDIT_OPTIONS_IMAGE_QUALITY_PLAYBACK: + return { + selectedComponent: { + ...selectedComponent, + options: { ...options, imageQualityPlayback: payload }, + }, + }; case Options.EDIT_OPTIONS_CLASSES: return { selectedComponent: { @@ -128,5 +135,37 @@ export function optionsReducer(state, action: OptionsActions) { options: { ...options, maxWords: payload }, }, }; + case Options.EDIT_OPTIONS_EXPOSE_TO_CONTEXT: + return { + ...state, + selectedComponent: { + ...selectedComponent, + options: { ...options, exposeToContext: payload }, + }, + }; + case Options.EDIT_OPTIONS_ALLOW_PRE_POPULATION: + return { + ...state, + selectedComponent: { + ...selectedComponent, + options: { ...options, allowPrePopulation: payload }, + }, + }; + case Options.EDIT_OPTIONS_ALLOW_PRE_POPULATION_OVERWRITE: + return { + ...state, + selectedComponent: { + ...selectedComponent, + options: { ...options, allowPrePopulationOverwrite: payload }, + }, + }; + case Options.EDIT_OPTIONS_DISABLE_CHANGING_FROM_SUMMARY: + return { + ...state, + selectedComponent: { + ...selectedComponent, + options: { ...options, disableChangingFromSummary: payload }, + }, + }; } } diff --git a/designer/client/reducers/component/types.ts b/designer/client/reducers/component/types.ts index ee4e8549e1..c0cf5c03d7 100644 --- a/designer/client/reducers/component/types.ts +++ b/designer/client/reducers/component/types.ts @@ -40,6 +40,7 @@ export enum Options { EDIT_OPTIONS_REQUIRED = "EDIT_OPTIONS_REQUIRED", EDIT_OPTIONS_HIDE_OPTIONAL = "EDIT_OPTIONS_HIDE_OPTIONAL", EDIT_OPTIONS_FILE_UPLOAD_MULTIPLE = "EDIT_OPTIONS_FILE_UPLOAD_MULTIPLE", + EDIT_OPTIONS_IMAGE_QUALITY_PLAYBACK = "EDIT_OPTIONS_IMAGE_QUALITY_PLAYBACK", EDIT_OPTIONS_CLASSES = "EDIT_OPTIONS_CLASSES", EDIT_OPTIONS_MAX_DAYS_IN_FUTURE = "EDIT_OPTIONS_MAX_DAYS_IN_FUTURE", EDIT_OPTIONS_MAX_DAYS_IN_PAST = "EDIT_OPTIONS_MAX_DAYS_IN_PAST", @@ -51,6 +52,10 @@ export enum Options { EDIT_OPTIONS_MAX_WORDS = "EDIT_OPTIONS_MAX_WORDS", EDIT_OPTIONS_PREFIX = "EDIT_OPTIONS_PREFIX", EDIT_OPTIONS_SUFFIX = "EDIT_OPTIONS_SUFFIX", + EDIT_OPTIONS_EXPOSE_TO_CONTEXT = "EDIT_OPTIONS_EXPOSE_TO_CONTEXT", + EDIT_OPTIONS_ALLOW_PRE_POPULATION = "EDIT_OPTIONS_ALLOW_PRE_POPULATION", + EDIT_OPTIONS_ALLOW_PRE_POPULATION_OVERWRITE = "EDIT_OPTIONS_ALLOW_PRE_POPULATION_OVERWRITE", + EDIT_OPTIONS_DISABLE_CHANGING_FROM_SUMMARY = "EDIT_OPTIONS_DISABLE_CHANGING_FROM_SUMMARY", } export const Actions = { diff --git a/designer/client/section/section-edit.js b/designer/client/section/section-edit.js index d23a006f5d..590e08a3a7 100644 --- a/designer/client/section/section-edit.js +++ b/designer/client/section/section-edit.js @@ -1,7 +1,8 @@ import React from "react"; import randomId from "../randomId"; -import { withI18n } from "../i18n"; +import { i18n, withI18n } from "../i18n"; import { Input } from "@govuk-jsx/input"; +import { Checkboxes } from "@xgovformbuilder/govuk-react-jsx"; import { validateName, validateTitle, @@ -11,6 +12,7 @@ import ErrorSummary from "../error-summary"; import { DataContext } from "../context"; import { addSection } from "../data"; import logger from "../plugins/logger"; +import { Actions } from "../reducers/component/types"; class SectionEdit extends React.Component { static contextType = DataContext; @@ -24,6 +26,7 @@ class SectionEdit extends React.Component { this.state = { name: section?.name ?? randomId(), title: section?.title ?? "", + hideTitle: section?.hideTitle ?? false, errors: {}, }; } @@ -35,11 +38,11 @@ class SectionEdit extends React.Component { if (hasValidationErrors(validationErrors)) return; const { data, save } = this.context; - const { name, title } = this.state; + const { name, title, hideTitle } = this.state; let updated = { ...data }; if (this.isNewSection) { - updated = addSection(data, { name, title: title.trim() }); + updated = addSection(data, { name, title: title.trim(), hideTitle }); } else { const previousName = this.props.section?.name; const nameChanged = previousName !== name; @@ -59,6 +62,7 @@ class SectionEdit extends React.Component { }); } copySection.title = title; + copySection.hideTitle = hideTitle; } try { @@ -110,7 +114,7 @@ class SectionEdit extends React.Component { render() { const { i18n } = this.props; - const { title, name, errors } = this.state; + const { title, name, hideTitle, errors } = this.state; return ( <> @@ -151,6 +155,27 @@ class SectionEdit extends React.Component { errors?.name ? { children: errors?.name.children } : undefined } /> +
    +
    + this.setState({ hideTitle: e.target.checked })} + /> + + + {i18n("sectionEdit.hideTitleField.helpText")} + +
    +
    {" "} diff --git a/designer/package.json b/designer/package.json index b1e341900c..10fb183c94 100644 --- a/designer/package.json +++ b/designer/package.json @@ -15,11 +15,11 @@ "lint": "yarn run eslint .", "fix-lint": "yarn run eslint . --fix", "symlink-env": "./bin/symlink-config", - "test": "yarn lint && yarn test-lab && yarn jest", - "test-cov": "yarn lint && yarn test-lab-cov && yarn jest && yarn merge-coverage", - "test:dev": "lab -T test/.transform.js test/.setup.js ./**/*.test.* -S -r console -o stdout -r html -o unit-test.html -I version -l --coverage-exclude client --coverage-exclude server --coverage-exclude config.js", - "test-lab": "lab -T test/.transform.js test/.setup.js ./**/*.test.* -S -v -r console -o stdout -r html -o unit-test.html -I version -l", - "test-lab-cov": "lab -T test/.transform.js test/.setup.js ./**/*.test.* -S -v -t 88 -r console -o stdout -r lcov -o test-coverage/lab/lcov.info -r html -o test-coverage/lab/unit-test.html -r junit -o test-results/lab/unit-test.xml -I version -l", + "test": "yarn lint && yarn test:dev && yarn jest", + "test-cov": "yarn lint && yarn test:dev && yarn jest", + "test:dev": "lab -T test/.transform.js test/.setup.js ./**!(node_modules)/*.test.* -S -r console -o stdout -r html -o unit-test.html -I version -l --coverage-exclude client --coverage-exclude server --coverage-exclude config.js", + "test-lab": "lab -T test/.transform.js test/.setup.js ./**!(node_modules)/.test.* -S -v -r console -o stdout -r html -o unit-test.html -I version -l", + "test-lab-cov": "lab -T test/.transform.js test/.setup.js ./**!(node_modules)/*.test.* -S -v -t 88 -r console -o stdout -r lcov -o test-coverage/lab/lcov.info -r html -o test-coverage/lab/unit-test.html -r junit -o test-results/lab/unit-test.xml -I version -l", "jest": "yarn jest:client && yarn jest:server", "jest:client": "jest --coverage --config=jest.client.config.js -w", "jest:server": "jest --coverage --config=jest.server.config.js --runInBand", @@ -53,12 +53,13 @@ "hoek": "^6.1.3", "i18next": "^21.6.12", "i18next-http-backend": "^1.3.2", - "joi": "17.2.1", - "jszip": "^3.7.1", + "joi": "^17.9.1", + "jszip": "^3.10.1", "lodash": "^4.17.21", "nanoid": "^3.3.4", "nodemon": "^2.0.20", "nunjucks": "3.2.1", + "path-to-regexp": "1.8.0", "react": "16.13.1", "react-dom": "16.13.1", "react-helmet": "^6.1.0", @@ -71,23 +72,20 @@ "yar": "^9.1.0" }, "devDependencies": { - "@babel/cli": "^7.10.5", - "@babel/core": "^7.11.1", - "@babel/eslint-parser": "^7.17.0", - "@babel/eslint-plugin": "^7.16.5", - "@babel/node": "^7.16.8", - "@babel/plugin-proposal-class-properties": "^7.16.7", - "@babel/plugin-proposal-export-namespace-from": "^7.16.7", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7", - "@babel/plugin-proposal-private-methods": "^7.16.11", + "@babel/cli": "^7.23.3", + "@babel/core": "^7.23.3", + "@babel/eslint-parser": "^7.23.3", + "@babel/eslint-plugin": "^7.22.10", + "@babel/node": "^7.22.19", + "@babel/plugin-proposal-export-default-from": "7.23.3", "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-transform-modules-amd": "^7.16.7", - "@babel/plugin-transform-runtime": "^7.17.0", - "@babel/preset-env": "7.16.11", - "@babel/preset-react": "7.16.7", - "@babel/preset-typescript": "^7.16.7", - "@babel/register": "7.17.0", - "@babel/runtime": "7.17.2", + "@babel/plugin-transform-modules-amd": "^7.23.3", + "@babel/plugin-transform-runtime": "^7.23.3", + "@babel/preset-env": "^7.23.3", + "@babel/preset-react": "^7.23.3", + "@babel/preset-typescript": "^7.23.3", + "@babel/register": "^7.22.15", + "@babel/runtime": "^7.23.3", "@hapi/code": "^8.0.7", "@hapi/eslint-plugin": "^6.0.0", "@hapi/lab": "^24.5.1", @@ -97,7 +95,6 @@ "@types/dagre": "^0.7.47", "@types/hapi": "^18.0.7", "@types/node": "^17.0.21", - "@types/pino": "^7.0.5", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.11", "@typescript-eslint/eslint-plugin": "^5.13.0", @@ -106,7 +103,7 @@ "acorn": "8.7.0", "autoprefixer": "^10.4.2", "babel-eslint": "^10.1.0", - "babel-loader": "^8.2.3", + "babel-loader": "^8.3.0", "babel-plugin-module-name-mapper": "^1.2.0", "babel-plugin-module-resolver": "^4.1.0", "babel-plugin-transform-import-ignore": "^1.1.0", @@ -135,7 +132,9 @@ "jsdom": "^19.0.0", "lcov-result-merger": "^3.1.0", "mini-css-extract-plugin": "^0.11.2", - "msw": "^0.47.4", + "msw": "^1.3.2", + "nodemon": "^2.0.20", + "nunjucks": "^3.2.3", "postcss": "^8.2.4", "postcss-loader": "^4.1.0", "prismjs": "1.27.0", @@ -145,7 +144,7 @@ "sinon": "^13.0.1", "standard": "16.0.4", "ts-node-dev": "^1.1.8", - "typescript": "^4.1.3", + "typescript": "4.9.5", "webpack": "^4.44.2", "webpack-bundle-analyzer": "^4.3.0", "webpack-cli": "^3.3.12", diff --git a/designer/test/.transform.js b/designer/test/.transform.js index 8bcad41c89..cfd2cc64c9 100644 --- a/designer/test/.transform.js +++ b/designer/test/.transform.js @@ -55,9 +55,9 @@ internals.transform = function (content, filename) { auxiliaryCommentAfter: "$lab:coverage:on$", plugins: [ "@babel/plugin-proposal-export-default-from", - "@babel/plugin-proposal-class-properties", - "@babel/plugin-proposal-private-property-in-object", - "@babel/plugin-proposal-private-methods", + "@babel/plugin-transform-class-properties", + "@babel/plugin-transform-private-property-in-object", + "@babel/plugin-transform-private-methods", "@babel/plugin-transform-runtime", "@babel/plugin-syntax-dynamic-import", [ diff --git a/designer/webpack.config.js b/designer/webpack.config.js index 7d56bb0813..8ded84cd1d 100644 --- a/designer/webpack.config.js +++ b/designer/webpack.config.js @@ -37,7 +37,12 @@ const client = { rules: [ { test: /\.(js|jsx|tsx|ts)$/, - exclude: /node_modules/, + exclude: [ + { + test: /node_modules/, + exclude: /pino/, + }, + ], loader: "babel-loader", }, { diff --git a/docker-compose.smoke.yml b/docker-compose.smoke.yml index e72ebf0af8..759bdc52ea 100644 --- a/docker-compose.smoke.yml +++ b/docker-compose.smoke.yml @@ -36,6 +36,8 @@ services: - sandbox=true - PREVIEW_MODE=true - NODE_ENV=test + - ALLOW_USER_TEMPLATES=true + - NODE_CONFIG={"documentUploadApiUrl":null} command: yarn runner start depends_on: - redis diff --git a/docker-compose.yml b/docker-compose.yml index 08a2c8f161..974f65cca5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,11 +40,59 @@ services: - PREVIEW_MODE=true - LAST_COMMIT - LAST_TAG + # - ENABLE_QUEUE_SERVICE=true + # - QUEUE_DATABASE_URL=mysql://root:root@mysql:3306/queue # or postgres://user:root@postgres:5432/queue + # - DEBUG="prisma*" + # - QUEUE_TYPE="MYSQL" command: yarn runner start depends_on: - - redis + redis: + condition: service_started + # mysql: + # condition: service_healthy redis: image: "redis:alpine" command: redis-server --requirepass 123abc ports: - "6379:6379" +# if using MYSQL, uncomment submitter +# submitter: +# image: digital-form-builder-submitter +# build: +# context: . +# dockerfile: ./submitter/Dockerfile +# ports: +# - "9000:9000" +# environment: +# - PORT=9000 +# - QUEUE_DATABASE_URL=mysql://root:root@mysql:3306/queue +# - QUEUE_POLLING_INTERVAL=5000 +# - DEBUG="prisma*" +# command: yarn submitter start +# depends_on: +# mysql: +# condition: service_healthy +# mysql: +# container_name: mysql +# image: "mysql:latest" +# command: --default-authentication-plugin=mysql_native_password +# ports: +# - "3306:3306" +# environment: +# MYSQL_ROOT_PASSWORD: root +# MYSQL_DATABASE: queue +# healthcheck: +# test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] +# timeout: 20s +# retries: 10 + +# use psql if you want a PostgreSQL based queue (recommended) +# postgres: +# container_name: postgres +# image: "postgres:16" +# ports: +# - "5432:5432" +# environment: +# POSTGRES_DB: queue +# POSTGRES_PASSWORD: root +# POSTGRES_USER: user diff --git a/docs/adr/0003-submitter-diagram.svg b/docs/adr/0003-submitter-diagram.svg new file mode 100644 index 0000000000..cc93e82dc8 --- /dev/null +++ b/docs/adr/0003-submitter-diagram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/adr/0003-submitter.md b/docs/adr/0003-submitter.md new file mode 100644 index 0000000000..1cb3b72967 --- /dev/null +++ b/docs/adr/0003-submitter.md @@ -0,0 +1,67 @@ +# Submitter and DB queue + +- Status: [ accepted ] | superseded by [ADR-0004](./0004-submitter.md) +- Deciders: FCDO / OS maintainers: [@jenbutongit](https://github.com/jenbutongit) [@superafroman](https://github.com/superafroman) +- Date: [2023-07-14] + +## Context and Problem Statement + +After a user submits their form, if there is an issue with submission, whether on the runner side, or webhook, this submission is essentially "lost". + +We do provide logging of the full payload if there is an error, however would require support staff to manually re-enter details. + +## Decision Drivers + +- We have "lost" submissions due to webhook errors +- It is likely all transactional services will require to implement a queue or some failsafe to ensure submissions aren't lost + - We are building a new service built in node, so have decided to implement this directly into the form builder + +## Considered Options + +Terminology: + +- Submission: User's data to be sent to a webhook +- Submitter: A new process which will become responsible for POST-ing or PUT-ing data to the webhook +- Queue: A database which will store a user's submission. This can also be used for auditing purposes + +### Option 1 + +1. After a user submits a form, push the payload into the database, including any additional information required +2. A separate microservice "Submitter" will poll the database for unsent or failed entries +3. Submitter posts to the webhook, and the webhook will respond to the submitter +4. On response, update the entry and flag it as "successful" with reference code if applicable +5. The runner will continuously poll the database for 2 seconds to check if there is an update or reference number +6. As long as the database insertion described in (1) is successful, the user will see the success page + +[Prisma ORM](https://www.prisma.io/) will be used. This will allow us to use the same interfaces, regardless of which database you wish to use. +Prisma supports PostgreSQL, MySQL, MariaDB, SQLite, AWS Aurora (inc serverless), Microsoft SQL Server, Azure SQL, MongoDB, CockroachDB. +[Full details on supported versions on their website](https://www.prisma.io/docs/reference/database-reference/supported-databases). + +Prisma will be able to generate migrations for your new or existing database. + +### Option 2 + +1. After a user submits a form, push the payload into the database, including any additional information required +2. A separate microservice "Submitter" will poll the database for unsent or failed entries +3. Submitter posts to the webhook, and the webhook will respond to the submitter +4. On response, update the entry and flag it as "successful" with reference code if applicable +5. The runner will continuously poll an endpoint on the submitter, like `/status` rather than the database directly + +- This allows us to manage database connections or lockups, and allow us to apply rate limiting however will take more time to implement + +### Option 3 + +Use a native queue service, like SQS. + +## Decision Outcome + +Option 1 for now, however an upgrade to Option 2 will be possible. + +Most transactional services will already have a database, or be able to provision one simply. +Databases are easily set up via Docker, enabling faster local development. We will also circumvent any vendor lock-in this way (AWS vs Azure vs GCP), +however, given a strong need from the OS community, adapters or subclasses can be written at a later date. + +You will not be required to use this new process, however will be advised that you do! +You will also be able to implement your own submitter if you do not want to use this solution. + +See supplementary sequence diagram [0003-submitter-diagram.svg](./0003-submitter-diagram.svg) diff --git a/docs/adr/0004-submitter.md b/docs/adr/0004-submitter.md new file mode 100644 index 0000000000..49d52178f6 --- /dev/null +++ b/docs/adr/0004-submitter.md @@ -0,0 +1,41 @@ +# Submitter and DB queue - Upgrade to PostgreSQL and pg-boss + +- Status: [ accepted ] +- Deciders: FCDO / OS maintainers: [@jenbutongit](https://github.com/jenbutongit) [@superafroman](https://github.com/superafroman) +- Date: 2024-01-12, updated 2024-01-17 + +## Context and Problem Statement + +This is an addition to [0003-submitter.md](./0003-submitter.md). ADRs 0003-submitter and 0004-submitter aim to make your services more reliable and resilient. + +As stated in [0003-submitter.md](./0003-submitter.md), an upgrade to option 2 would be possible. +~~Instead of polling a database for the reference number, it will make a GET request instead. This allows for better microservices architecture.~~ +To simplify architecture, for now, we will use the pg-boss utility method `getJobById`. Other queues do not typically implement this. + +## Decision Drivers + +### Better architectural decoupling (microservices) + +When queues are enabled the database, runner, submitter, and webhook endpoints are tightly coupled. Each of the services (runner, submitter and webhook) +are communicating via a single entry. If the schema is required to change, it creates a breaking change across all 3 services, and the deployments need to be coordinated. + +By only allowing the runner to submit to a queue, and poll reference numbers via an API, we remove the coupling from the runner. It no longer needs to keep track of the databases' schema. + +### Performance + +#### PostgreSQL and pg-boss + +pg-boss relies on PostgreSQL to queue jobs and easily allow processing of jobs. It also leverages node's event features (e.g. events and event emitter) to improve performance. +PostgreSQL has features with messaging built in mind, for example SKIP LOCKED to ensure rows are not being read or written by different processes. The MySQL based submitter does not currently do this. + +### Support/Maintenance + +Pg-boss is a lot more feature rich than our mysql based submitter. It has support for exponential backoff, pub/sub, automatic creation of tables and more. +It's far too much for us to write our own equivalent! There are some equivalent libraries available for MySQL, but are not as feature rich, or as well maintained. + +The pg-boss implementation will also allow easier support for other queues, like SQS, Kafka, RabbitMQ. All with their own libraries or SDKs that allow events to be emitted and consumed easily. + +### Negative Consequences + +- The MySQL queue is likely to be deprecated due to support/maintenance overheads. +- For the time being, both queue types will be supported, which means that there is more code to maintain and some additional complexity diff --git a/docs/adr/0005-runner-pulls-from-source.md b/docs/adr/0005-runner-pulls-from-source.md new file mode 100644 index 0000000000..06e386b0f5 --- /dev/null +++ b/docs/adr/0005-runner-pulls-from-source.md @@ -0,0 +1,43 @@ +# Change the runner to pull forms from a source + +- Status: [proposed] +- Deciders: OS maintainers [@jenbutongit](https://github.com/jenbutongit) [@superafroman](https://github.com/superafroman) [@ziggy-cyb](https://github.com/ziggy-cyb) +- Date: [2023-11-28 when the decision was last updated] + +## Context and Problem Statement + +Currently, when the user saves the form, it sends the entire form to the server, which optionally saves the form to S3 if configured, then +sends a post request to the runner (in preview mode), to `/publish`, and the form is initialised and saved in memory. + +For production environments, we recommend that you replace the docker images files `dist/server/forms` with the forms that should be available on startup, and have PREVIEW_MODE turned off. + +## Decision Drivers + +- Desire to make this into a SaaS product! +- Forms can be accessed when published, without having to redeploy + +## Considered Options + +- [option 1] Forms are no longer "pushed" to the runner. The runner would be configured with a source + (e.g. S3, mountpoint/filesystem/database/designer) and would pull forms and initialise them if requested. + +## Decision Outcome + +Chosen option: + +## Pros and Cons of the Options + +### [option 1] + +When a user requests a form at /forms/:id, the runner will pull from the configured source, and initialise the form. +This gives developers flexibility on how they wish to store, manage and "publish" forms. Newly published forms will therefore always be "available". + +When horizontally scaling the pod, it does not need to initialise all the forms that have been published or on the image, only the ones that were requested of that pod. +There may still need to be a way to "register" which pods have which forms, and prioritise requests to pods that have the form already initialised. +Session stickiness may also be required (to prevent a user from being redirected to a pod that does not have the form initialised). + +Handling different versions of forms should also be considered. We can send, in the metadata, the version of the form. The webhook can then determine what to do with this version. + +For the designer "source", we would just be moving the /publish and /published endpoints from the runner to the designer. + +### [option 2] diff --git a/docs/adr/0006-atomic-saves.md b/docs/adr/0006-atomic-saves.md new file mode 100644 index 0000000000..a68610ed17 --- /dev/null +++ b/docs/adr/0006-atomic-saves.md @@ -0,0 +1,88 @@ +# Designer - Atomic saves on the server + +- Status: [proposed] +- Deciders: OS maintainers [@jenbutongit](https://github.com/jenbutongit) [@superafroman](https://github.com/superafroman) [@ziggy-cyb](https://github.com/ziggy-cyb) +- Date: [2023-11-28 when the decision was last updated] + +## Context and Problem Statement + +Currently, when a form is edited, changes are made to a version of the form saved in memory on the browser (react state). +When the user saves, it sends the entire form to the server to be validated and then persisted. + +## Decision Drivers + +- Handling state (entirety of the form) on the client can get complex and difficult to test +- Saving "chunks" of the form can help with analytics or future features like edit history +- The designer is entirely client side rendered, it will not work without javascript enabled + - React server side components is not performant. (Neither is React for that matter!) +- By splitting the designer, it allows the community to "bring their own frontend" (they do not need to use GOV.UK styles) + +## Considered Options + +- [option 1] Move persistence to the designer server, and make the form a RESTful resource + - When the user edits a component, or page etc, this should send a request to the designer server, save, and return the updated form. The server becomes responsible for making the mutation to the persisted form. + - Since every "thing" in the form (components, page, etc) has an id, and some "things" being nested children (like list items or components), form JSONs can easily follow a RESTful structure. +- [option 2] Rewrite the whole thing in Svelte(Kit) (or more sensibly, next.js) + +## Decision Outcome + +Chosen option: [option 1]. + +### [option 1] + +Most of the application is using reducers to manage state. For example, list reducers, are already split into distinct actions. +That gives us the option to + +- migrate gradually, action by action +- migrate gradually, by reducer type (list, component, page) + +At first, the designer (client) will have to construct the URLs for the request. + +The form resource may look like this: + +``` +GET | POST | PUT /forms/:id +GET | POST | PUT /forms/:id/pages/:pagePath +GET | POST | PUT /forms/:id/pages/:pagePath/next +GET | POST | PUT /forms/:id/pages/:pagePath/title +GET | POST | PUT /forms/:id/pages/:pagePath/components/:componentId +GET | POST | PUT /forms/:id/lists/:listId +GET | POST | PUT /forms/:id/lists/:listId/items/:itemId +``` + +The post requests, can send an action name, and the payload (this is how the reducers currently work), and the server will make the change. + +The get request can either be used to just return the JSON, or render the page which allows the user to edit this component. +For example, to edit the component "name" on "/passport-details", the URL would be `/forms/:id/pages/passport-details/components/name`. +When the user submits the form on this page, it will post the same URL, with the action and payload. We can then use the post, redirect, get pattern to render the page with errors (if any). + +#### Positives + +- Accessibility and usability + - React and "the React way" makes it difficult for developers to think about accessibility and usability +- Handling multiple versions of the schema is simpler. We can just mount the routers to /v1, /v2 +- Testing is simpler +- More developer "concurrency" (both frontend and backend development can happen simultaneously) +- No longer mutating the entire form and sending, which makes the editing process "safer" +- We can explore using GOV.UK nunjucks to render the pages in future, which allows us to keep components up to date easily. React GOV.UK components are only provided as open source projects + +#### Negatives + +Client side rendering is often a consideration to reduce server load, so we will be losing this. But at low volumes of traffic it is not a concern. +It's also a lot of work (even if it can be done incrementally) + +### Rewrite in a web meta framework like next.js + +#### Positives + +Following the same API design as option 1, we would use next.js (because it is already compatible with react). Next.js can provide server side rendering, and file system based routing. +Developer experience of meta frameworks is typically very good. + +#### Negatives + +This would have to be worked on in parallel to the current designer, and released all at once. It also introduces tight coupling between the server and frontend. + +## Links + +- [Link type] [Link to ADR] +- … diff --git a/docs/adr/0007-mid-journey-save-return.md b/docs/adr/0007-mid-journey-save-return.md new file mode 100644 index 0000000000..050bed29c8 --- /dev/null +++ b/docs/adr/0007-mid-journey-save-return.md @@ -0,0 +1,215 @@ +# Mid journey save and returns + +- Status: [proposed] +- Deciders: OS maintainers [@jenbutongit](https://github.com/jenbutongit) [@superafroman](https://github.com/superafroman) [@ziggy-cyb](https://github.com/ziggy-cyb) + +- Date: [2024-06-13 when the decision was last updated] + +## Context and Problem Statement + +It is currently possible to inject a full or partial session _into_ the runner, but it is not possible to send session data +externally in the middle of a form. For long forms, or forms which require the user to get a document or find out more +information, they are unable to exit the form without losing all their data. + +## Considered Options + +- Option 1 + - Add a flag, or flags (perhaps on certain pages only?), to the form configuration, e.g. `allowSaveAndExit`, which will render a `Save and exit` button at the bottom of each question page + - The data is then POSTed to a 3rd party application, which will then store the data in their chosen database + - Optionally, add a data format to webhook outputs, which matches the state as stored in redis + - Optionally, add a data format to the `/sessions/{formId}` endpoint, which matches the state, as stored in redis + - The 3rd party application must also handle rehydrating the user's session, and managing how they re-enter the application + - after successful exit, show the user a success screen (customised similarly to the current application complete page), or allow the user to be redirected externally. +- Option 2 + - Add a flag, or flags (perhaps on certain pages only?), to the form configuration, e.g. `allowSaveAndExit`, which will render a `Save and exit` button at the bottom of each question page + - The runner stores this in redis with a longer ttl, or another database (possibly postgres), and sends the user a link so that they can return to their session + - after successful exit, show the user a success screen (customised similarly to the current application complete page), or allow the user to be redirected externally. + +## Decision Outcome + +## Decision Outcome + +Chosen option: "[option 1]", only teams with developers have asked for this feature. This will help us reduce the maintenance burden. +It also allows for flexibility on how users return to their journey (e.g. you may have a "task list" page on your application, which is authenticated). + +## Pros and Cons of the Options + +### [option 1] + +- Good, because it encourages engineers to develop in a microservice architecture. In future, if the runner needs to be replaced, + you will not need to rewrite the session hydration part of the application. +- Good, because teams looking to implement this feature may already have a preferred data store, and possibly an API which can serve this data. + XGovFormBuilder will not lock teams into tech they are not familiar with, or add superfluous tech to their stack +- Good, because the only "required" additional technology XGovFormBuilder requires is currently Redis. + However, Redis is not designed for long term storage. +- Good, because it does not lock teams/users into a specific user journey. In this instance, we would only allow users to return via a URL emailed to them. +- Bad, because it raises the bar to entry, a development team will be required. + +We have also suggested that we add additional data formats to /sessions/{formId} and the webhook output. This is to improve +developer experience and simplify making session rehydration calls. The webhook output format which is required by /sessions/{formId} +is fairly verbose. It includes information like the page title, section, key and answer. This is so that there is reduced +data loss, in the event that forms are changed. Unrecognised keys can still be placed in a generic description field, along with the page title. + +Some teams may only care about the key/answer, and accept the risk or have mitigated it in other ways when components names have changed. +This means that there would be an extra step for them to translate to/from this data format. + +The data format changes can be worked on separately to the save and return feature, but this is a good time to add additional support. + +Below is a comparison of the webhook format, and the state format. + +In redis, the data will be stored like so + +```json5 +{ + progress: [], + checkBeforeYouStart: { + ukPassport: true, + }, + applicantDetails: { + numberOfApplicants: 2, + phoneNumber: "123", + emailAddress: "a@b", + languagesProvided: ["fr", "it"], + contactDate: "2024-12-25T00:00:00.000Z", + }, + applicantOneDetails: { + firstName: "Winston", + lastName: "Smith", + address: { + addressLine1: "1 Street", + town: "London", + postcode: "ec2a4ps", + }, + }, + applicantTwoDetails: { + firstName: "Big", + lastName: "Brother", + address: { + addressLine1: "King Charles Street", + town: "London", + postcode: "SW1A 2AH", + }, + }, +} +``` + +When making calls to /sessions/{formId}, the payload can be shortened slightly from the webhook output, since `question`, `title`, and `type` are not required. + +```json5 +{ + name: "Digital Form Builder - Runner undefined", + metadata: {}, + questions: [ + { + category: "checkBeforeYouStart", + fields: [ + { + key: "ukPassport", + answer: true, + }, + ], + index: 0, + }, + { + category: "applicantDetails", + fields: [ + { + key: "numberOfApplicants", + answer: 2, + }, + ], + index: 0, + }, + { + category: "applicantOneDetails", + fields: [ + { + key: "firstName", + answer: "Winston", + }, + { + key: "lastName", + answer: "Smith", + }, + ], + index: 0, + }, + { + category: "applicantOneDetails", + fields: [ + { + key: "address", + answer: "1 Street, London, ec2a4ps", + }, + ], + index: 0, + }, + { + category: "applicantTwoDetails", + question: "Applicant 2", + fields: [ + { + key: "firstName", + title: "First name", + type: "text", + answer: "big", + }, + { + key: "lastName", + title: "Surname", + type: "text", + answer: "brother", + }, + ], + index: 0, + }, + { + category: "applicantTwoDetails", + question: "Address", + fields: [ + { + key: "address", + title: "Address", + type: "text", + answer: "King Charles Street, London, SW1A 2AH", + }, + ], + index: 0, + }, + { + category: "applicantDetails", + fields: [ + { + answer: ["fr", "it"], + key: "languagesProvided", + }, + ], + index: 0, + question: "Which languages do you speak?", + }, + { + category: "applicantDetails", + fields: [ + { + key: "phoneNumber", + answer: "123", + }, + { + key: "emailAddress", + answer: "a@b", + }, + { + answer: "2024-12-25", + type: "date", + }, + ], + index: 0, + }, + ], +} +``` + +### [option 2] + +- Good, because it simplifies enabling this feature. External applications/microservices do not need to be written. +- Bad, because it may require teams to run additional databases. XGovFormBuilder maintainers will need to write multiple adapters for different types of databases. diff --git a/docs/designer/query-param-field.png b/docs/designer/query-param-field.png new file mode 100644 index 0000000000..a937abd2c4 Binary files /dev/null and b/docs/designer/query-param-field.png differ diff --git a/docs/designer/query-param-prepopulation.md b/docs/designer/query-param-prepopulation.md new file mode 100644 index 0000000000..90bf47e5e5 --- /dev/null +++ b/docs/designer/query-param-prepopulation.md @@ -0,0 +1,26 @@ +# Query parameter pre-population + +In some cases users might have filled in their information on a different site before being directed through to your form. In these cases, it might be a better user experience for these bvalues to be pre-populated. + +To allow this, the form builder supports query parameter pre-population, allowing values in the form to be pre-populated via query parameter. + +## Setup + +Access to query param pre-population is set at the component level. + +To allow a field to be pre-populated, tick the "allow query parameter pre-population" checkbox on the field configuration: + +![The query parameter pre-population field appears underneath the "expose to context" field in the field configuration panel](./query-param-field.png) + +Once pre-population is allowed on a field, you can pre-populate that field by appending a query parameter with the component name to a form url e.g. `https://your-forms-url/your-form/target-page?firstName=Joe&lastName=Bloggs`. + +## allowPrePopulationOverwrite + +By default, if a field marked for pre-population already has state set in the user's session, the incoming value will be ignored. +Sometimes you might want a query parameter to always overwrite the current state, for example, if there is a hidden field that the user will change through links with different query parameters. +To allow this, you can pass a second option to the component, `allowPrePopulationOverwrite`. + +## caveats + +- For the time being, due to complications with validation, this functionality is only available to list type components e.g. select fields or autocomplete fields. +- If the field is in a section, then the query param will need to be passed with dot notation e.g. `yourDetails.firstName=Joe`. diff --git a/docs/runner/document-upload.md b/docs/runner/document-upload.md new file mode 100644 index 0000000000..78c887e0ac --- /dev/null +++ b/docs/runner/document-upload.md @@ -0,0 +1,32 @@ +# Document upload + +the form builder supports the use of an external document upload service. This allows users to upload files, but gives developers the flexibility to decide how they want to process the files. + +## Setup + +In order to start using file upload files in your form, you will need to specify an endpoint to send your files to. This can be done by setting the following environment variables: + +| Variable name | Definition | example | +| ----------------------- | ------------------------------------------------------ | ------------------------------- | +| DOCUMENT_UPLOAD_API_URL | the root endpoint of service used to upload your files | https://document-upload-api.com | + +The service you're using for your document upload api will need an endpoint of /files that accepts POST requests with a file in the body. Currently, there is no support for authenticating against this endpoint, so this endpoint will need to be open. + +### Responses + +The upload service which handles communication with the api can handle the following responses: + +| Code | Payload | Handled by | +| ---- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 201 | | Updating the value of the upload field to the location url returned | +| 201 | `qualityWarning` | Updating the upload field as above, as well as routing to the upload playback page if using the UploadPageController. See below for more details. | +| 400 | | Redirects back to the upload field page displaying a file type error | +| 413 | | Redirects back to the upload field page displaying a file size error | +| 422 | | Redirects back to the upload field page displaying a file virus error | + +#### UploadPageController + +We have introduced a specific UploadPageController, which can be used if you want to integrate image quality checking into your document upload api. +By adding the property `controller: UploadPageController` to the page in your form json, if a successful response is returned from the api but with the payload "qualityWarning", the user will be presented a playback page. +This page will strongly suggest the user upload a new image, and give the user the option to continue anyway or upload a new image. +If the UploadPageController is not specified on the page, the quality warning will be ignored, and the user will be routed to the next page in the form as normal. diff --git a/docs/runner/fee-options.md b/docs/runner/fee-options.md new file mode 100644 index 0000000000..38ea0ed03f --- /dev/null +++ b/docs/runner/fee-options.md @@ -0,0 +1,177 @@ +# Fee options and Payment skipped warning page + +## Fee options + +`feeOptions` is a top level property in a form json. Fee options are used to configure API keys for GOV.UK Pay, and the behaviour of retrying payments. + +```json5 +{ + // pages, sections, conditions etc .. + feeOptions: { + /** + * If a payment is required, but the user fails, allow the user to skip payment + * and submit the form. this is the default behaviour. + * + * Any versions AFTER (and not including) v3.25.68-rc.927 allows this behaviour + * to be configurable. If you do not want payment to be skippable, set + * `allowSubmissionWithoutPayment: false` + */ + allowSubmissionWithoutPayment: true, + + /** + * The maximum number of times a user can attempt to pay before the form is auto submitted. + * There is no limit when allowSubmissionWithoutPayment is false. (The user can retry as many times as they like). + */ + maxAttempts: 3, + + /** + * A supplementary error message (`customPayErrorMessage`) + */ + customPayErrorMessage: "Custom error message", + + /** + * Shows a link (button) below the "Submit and pay" button on the summary page. Clicking this will take the user to a page + * that provides additional messaging, you can warn the user that this may delay their application for example. + * allowSubmissionWithoutPayment must be true for this to be shown. + */ + showPaymentSkippedWarningPage: false, + + /** + * Adds metadata to the GOV.UK Pay request. You may add static values, or values based on the user's answer. + */ + additionalReportingColumns: [ + { + columnName: "country", + fieldPath: "beforeYouStart.country", // the path in the state object to retrieve the value from. If the value is in a section, use the format {sectionName}.{fieldName}. + }, + { + columnName: "post", + fieldPath: "post", + }, + { + columnName: "service", + staticValue: "fee 11", + }, + ], + }, +} +``` + +As a failsafe, if a user was not able to pay, we will allow them to try up to 3 times (`maxAttempts`), then auto submit (`"allowSubmissionWithoutPayment": true`). +This is the default behaviour. Makes sure you check your organisations policy or legislative requirements. You must ensure there is a process to remediate payment failures. + +When a user fails a payment, they will see the page [pay-error](./../../runner/src/server/views/pay-error.html). + +When `allowSubmissionWithoutPayment` is true, the user will also see a link which allows them to skip payment. + +### Recommendations + +If your service does not allow submission without payment, set +`allowSubmissionWithoutPayment: false`. `maxAttempts` will have no effect. The user will be able to retry as many times as they like. +You can provide them with `customPayErrorMessage` to provide them with another route to payment. + +## paymentSkippedWarningPage + +`paymentSkippedWarningPage` can be found on the `specialPages` top level property. + +If `feeOptions.showPaymentSkippedWarningPage` (and `feeOptions.allowSubmissionWithoutPayment`) is true, +another page ([payment-skip-warning](./../../runner/src/server/views/payment-skip-warning.html)) will be presented to the user. +Additional messaging can be provided to the user for alternative routes to payment, or may result in application delays. +The can choose to continue or try online payment. This page will be shown only once. + +```json5 +{ + // pages, sections, conditions etc .. + paymentSkippedWarningPage: { + customText: { + caption: "Payment", + title: "Pay at appointment", + body: '

    You have chosen to skip payment. You will not be able to submit your application until you have paid.

    If you are unable to pay online, you\'ll need to bring the equivalent of £50 in cash in the local currency to your appointment. You wil not be given any change. Check current consular exchange rates

    ', + }, + }, +} +``` + +## Reporting columns + +[GOV.UK Pay allows additional reporting columns to be configured](https://docs.payments.service.gov.uk/api_reference/create_a_payment_reference/#json-body-parameters-for-39-create-a-payment-39). +This is useful if you wish to filter payments in GOV.UK Pay. In FCDO's case, it is useful to filter by country selected by the user. + +You may add static values, or values based on the user's answer. You may only configure 10 reporting columns as per +GOV.UK Pay's limits, and ensure that each columnName is <30 characters. The values may be 100 characters. + +```json5 +{ + //.. + additionalReportingColumns: [ + { + columnName: "country", + fieldPath: "beforeYouStart.country", // the path in the state object to retrieve the value from. If the value is in a section, use the format {sectionName}.{fieldName}. + }, + { + columnName: "post", + fieldPath: "post", + }, + { + columnName: "service", + staticValue: "fee 11", + }, + ], +} +``` + +If the value at the fieldPath cannot be found, the column will not be added to the metadata. + +Given the user state looks like + +```.ts +const state = { + beforeYouStart: { + country: "United Kingdom", + } +} +``` + +This will be parsed and sent to GOV.UK Pay as: + +```.ts + const requestOptions = { + //.. GOV.UK Pay request + metadata: { + country: "United Kingdom", + // no post key since it's not present in the user's state, + service: "fee 11" + } + } +``` + +When viewing this payment in GOV.UK Pay, you can sort by these columns, and will appear as "metadata" in their interface. + +## Additional payment metadata + +With fee options, there is the possibility to send through more payment metadata to your webhook outputs. This metadata will tell your webhook about the status of the payment, the payment id, and the payment reference. + +If you require this metadata, add the `sendAdditionaPayMetadata` option to your webhook output configuration as below: + +```json5 +{ + name: "outputName", + title: "Webhook output", + type: "webhook", + outputConfiguration: { + url: "https://some-url.com", + sendAdditionalPayMetadata: true, + }, +} +``` + +## Other - Reference numbers + +Reference numbers are generated with the alphabet "1234567890ABCDEFGHIJKLMNPQRSTUVWXYZ-\_". Note that the letter O is omitted. + +You may configure the length of the reference number by setting the environment variable `PAY_REFERENCE_LENGTH`. The default is 10 characters. +Use [Nano ID Collision Calculator](https://zelark.github.io/nano-id-cc/) to determine the right length for your service. +Since each user will "keep" their own reference number for multiple attempts, calculate the speed at unique users per hour. + +e.g. If your service expects 100,000 users per annum, you should expect ~274 users per day, and 11 users per hour. +Using nano-id-cc, and a reference length of 10 characters it will take 102 years, or 9 million IDs generated for a 1% chance of collision. diff --git a/docs/runner/multi-start-page.md b/docs/runner/multi-start-page.md new file mode 100644 index 0000000000..e4f0ece7dd --- /dev/null +++ b/docs/runner/multi-start-page.md @@ -0,0 +1,55 @@ +# Multi-page start pages + +Sometimes in your service it might be necessary to implement a multi-page start page, for example if there's two much content on one page. + +The form builder has a special page controller which can be used to achieve this effect. The controller matches the styles of the [carer's allowance start page](https://www.gov.uk/carers-allowance), the example for multi-page start pages, as closely as possible. + +## How it works + +To convert a standard page into a multi-page start page, you will need to add the following properties to your page definition: + +```json5 +{ + controller: "MultiStartPageController", + startPageNavigation: { + previous: { + labelText: "Previous page", + href: "/form-name/previous-page", + }, + next: { + labelText: "Next page", + href: "/form-name/next-page", + }, + }, +} +``` + +This will then create the page as a multi-page start page, with the previous and next page as paginated links. + +### Properties + +These are the multi-page specific properties which are either required or can be used to use other multi-page features. + +#### startPageNavigation - required + +`startPageNavigation` is a required property which is used to populate the pagination at the bottom of the page. + +It has a `previous` and `next` property which are used to populate each link, however both of these properties are optional in case a previous or next link cannot be specified. + +#### showContinueButton - optional + +`showContinueButton` is an optional property that defines whether the continue button should be shown on the page. The continue button is hidden by default, however if the flag is set `showContinueButton: true`, a continue button will be shown above the pagination footer. + +#### continueButtonText - optional + +`continueButtonText` defines the text to be shown on the continue button. By default, the button will say "Continue", however it may be necessary for you to have different text for your call to action e.g "Apply now". + +### Recommendations + +For the multi-page start pages to work with the form they must be in the main flow of the form i.e between the first page and the summary page of the form. With this in mind, to get the desired effect of the start pages, your page that leads to the rest of the form should have `showContinueButton: true` and should be placed before the actual start of the form. + +An example of this can be seen by looking at the `multi-page-example` form. + +If you want your call to action page to appear part way through your start pages, link this page to the start of your form questions, and keep the following start pages in an unreachable branch in the form. This way your latter start pages can still be accessed through the pagination in your start pages, however won't interrupt the flow of the form at all. + +The guidance for multi-page start pages advises putting all of your start pages under a shared heading, and then having a sub-heading outlining the name of the page. The best way to achieve this effect is to name all your start pages with the shared heading, but different paths, and then to add an HTML component with an `

    ` element providing your section title. diff --git a/docs/runner/redirects.md b/docs/runner/redirects.md new file mode 100644 index 0000000000..ad2e359475 --- /dev/null +++ b/docs/runner/redirects.md @@ -0,0 +1,54 @@ +# Redirects + +Pages in the form JSON can be configured to go to the next page in the form, or redirect to a new URL. This happens when the user +"continues" to the next page, and any field validations do not fail. + +To redirect to another URL, it must be a fully qualified URL (i.e. not a partial path). This will be useful if your service can be completed by another service or site. You must manually change the JSON to enable this feature. It is currently not supported in the designer. + +```json5 +{ + title: "Start", + path: "/start", + section: "beforeYouStart", + components: [ + { + name: "country", + type: "AutocompleteField", + title: "Country", + list: "SfkWjb", + }, + ], + next: [ + { + path: "/second-page", // next page in form + }, + { + redirect: "http://localhost:3009/help/cookies", // a URL you wish to redirect to + condition: "shouldRedirectToCookiesPage", + }, + ], +} +``` + +To go to the next page in the form, in the `next` array, add: + +```json5 +{ + path: "/second-page", // page.path of the next page in the form + condition: "..", // optional, set up a condition if you only want the user to go to this page if the condition succeeds +} +``` + +To redirect the user to another URL + +```json5 +{ + redirect: "http://localhost:3009/help/cookies", + condition: "shouldRedirectToCookiesPage", // optional +} +``` + +It is good practice to always have a page that does not have a condition attached, making it the "default" page. +This way, if the conditions fail to evaluate, the user will not see an error. + +See [redirects.json](../../e2e/cypress/fixtures/redirects.json) for a full example. diff --git a/docs/runner/session-initialisation-oas.yaml b/docs/runner/session-initialisation-oas.yaml new file mode 100644 index 0000000000..8768698b4a --- /dev/null +++ b/docs/runner/session-initialisation-oas.yaml @@ -0,0 +1,143 @@ +openapi: 3.0.0 +info: + title: Runner - initialise session + version: 1.0.0 +servers: + - url: http://localhost:3009 + +components: + schemas: + CallbackOptions: + type: object + properties: + callbackUrl: + description: The URL to send the PUT request to, after the user has completed the form + type: string + redirectPath: + description: Which page to send the user to, when after they've activated the session. Defaults to /{formId}/summary + type: string + message: + description: What to display at the top of the summary page + type: string + customText: + description: What to display on the application complete page. It mirrors the ConfirmationPage["customText"] schema. + type: object + properties: + paymentSkipped: + description: Setting to false disables this string from rendering on the application complete page. + oneOf: + - type: boolean + - type: string + nextSteps: + description: Setting to false disables this string from rendering on the application complete page. + oneOf: + - type: boolean + - type: string + components: + description: Any additional content components to display on the application complete page. It mirrors the ConfirmationPage["components"] schema. + type: array + items: + type: object + properties: + name: + type: string + options: + type: object + type: + type: string + content: + type: string + schema: + type: object + required: + - callbackUrl + + Question: + type: object + properties: + question: + type: string + category: + type: string + fields: + type: array + items: + type: object + properties: + key: + type: string + answer: + oneOf: + - type: string + - type: boolean + - type: number + required: + - key + - answer + + Metadata: + type: object + +paths: + /sessions/{formId}: + post: + summary: Submit a session with options and questions + parameters: + - name: formId + in: path + description: This must match the form JSONs' filename + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + options: + $ref: "#/components/schemas/CallbackOptions" + questions: + type: array + items: + $ref: "#/components/schemas/Question" + metadata: + $ref: "#/components/schemas/Metadata" + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + token: + type: string + "404": + description: Form not found + content: + application/json: + schema: + type: object + properties: + message: + type: string + "403": + description: Callback URL not allowed + content: + application/json: + schema: + type: object + properties: + message: + type: string + "400": + description: Both htmlMessage and message were provided + content: + application/json: + schema: + type: object + properties: + message: + type: string diff --git a/docs/runner/session-initialisation.md b/docs/runner/session-initialisation.md new file mode 100644 index 0000000000..877e446440 --- /dev/null +++ b/docs/runner/session-initialisation.md @@ -0,0 +1,96 @@ +# Session initialisation (rehydration) + +Sessions may be inserted into the form runner, and then activated by a user, given that they have the token. + +This is more suitable compared to [query-param-prepopulation](./designer/query-param-prepopulation.md) if you need to +prepopulate a lot of data or you do not want to send the user a URL with personal data in it. + +The general flow is: + +1. POST the user's session to `/session/{formId}` +1. The session will be stored in session storage (usually redis), as `{ [generatedToken]: sessionData }` +1. The POST request will respond with the generatedToken. The user will use this to activate their session +1. Your service sends the user (either by email, or showing them a URL on your service) + to `https://runner-url/session/{generatedToken}` +1. The user will be redirected to the configured pages (or to the summary page by default) +1. The session data is copied from `{ [generatedToken]: sessionData }`, to where it would "usually" go, which + is `{ [formId]: sessionData }` +1. Once activated, the token will be revoked, it cannot be used again + +## Environment variables + +| variable | type | example | description | +| ----------------------------- | -------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SAFELIST | string[] | your.service.gov.uk | These are the allowed hostnames you wish to PUT data to, after the user has completed the form from a rehydrated session | +| INITIALISED_SESSION_TIMEOUT | number | 2419200000 | Time, in ms, you wish to keep a rehydrated session in the redis instance for. It will delete after this time if a user does not activate it | +| INITIALISED_SESSION_KEY | string | super-s3cure-p4ssw0rd | The user's token is generated with this key, similarly, the user's session is decrypted with this key. ⚠️ You must ensure this is set if you are deploying replicas. You must also ensure you re-issue tokens if you change this key. | +| INITIALISED_SESSION_ALGORITHM | string | HS512 | HS512 is the default. You may use: `RS256`, `RS384`, `RS512`,`PS256`, `PS384`, `PS512`, `ES256`, `ES384`, `ES512`, `EdDSA`, `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `HS256`, `HS384`, `HS512`. ⚠️ You must reissue your tokens if you change this algorithm. | + +## Initialising a session + +See [session-initialisation-oas.yaml](./session-initialisation-oas.yaml) for the Open API specification. + +Sample payload for POSTing to `/session/{formId}`: + +```json5 +{ + options: { + callbackUrl: "https://b4bf0fcd-1dd3-4650-92fe-d1f83885a447.mock.pstmn.io", + redirectPath: "/summary", // after session is activated, user will be redirected to ${formId}/${redirectPath} + message: "Please fix this thing..", //message to display to the user on the summary page + customText: { + //same as ConfirmationPage["customText"] + paymentSkipped: false, + nextSteps: false, + }, + components: [ + // same as ConfirmationPage["components"] + { + name: "WLskhZ", + options: {}, + type: "Html", + content: "Thanks!", + schema: {}, + }, + ], + }, + questions: [ + { + fields: [ + { + key: "size", + answer: "Large firm (350+ legal professionals)", + }, + ], + }, + { + question: "Can you provide legal services and support to customers in English?", // optional. This makes no difference, but it is what is originally sent on a "fresh" application + category: "mySection", //optional - category is renamed to "section". You MUST provide the category/section if your form uses sections. + fields: [ + { + key: "speakEnglish", + answer: true, + }, + ], + }, + ], + metadata: { id: "abc-001" }, // any additional information you'd like to send to the callback Url +} +``` + +Sample response for POSTing to `/session/{formId}`: + +```json5 +{ + token: "efg-hi5-jk7", +} +``` + +The session will now be available for one time use at `localhost:3009/session/efg-hi5-jk7`. The user must submit the form, +otherwise they will need to request a token from you again. + +You may generate and email tokens on an automated basis, for example, once a year. But it is recommended that you have a +"landing page" on an external application, which presents the user with a link or button. After clicking this link, +your external application should then generate the token, and redirect the user to it. +It means you do not have to worry about tokens expiring, or reissuing tokens if your INITIALISED_SESSION_KEY +or INITIALISE_SESSION_ALGORITHM has changed. diff --git a/docs/runner/submission-queue.md b/docs/runner/submission-queue.md new file mode 100644 index 0000000000..a82ed057f1 --- /dev/null +++ b/docs/runner/submission-queue.md @@ -0,0 +1,200 @@ +# Submission queue + +The runner can be configured to add new submissions to a queue and, if using the MYSQL queue type, for this queue to be +processed by the submitter module. + +Two queue types are currently allowed, MYSQL and PGBOSS. + +For `MYSQL`, enabling the queue service this will change the webhook process, so that the runner will push the +submission to a +specified database, and will await a response from the submitter for a few seconds before returning the success screen +to the user. + +For `PGBOSS`, which handles events and queues as expected from event based architecture. The `PGBOSS` queue type +uses [pg-boss](https://www.npmjs.com/package/pg-boss). +You must have a postgres >v11 database configured. The runner will add job to the queue. It will then +poll `pgBoss.getJobById` for the reference number you wish to return to the user. You must implement this yourself. + +In future, we may add support for different types of queues, like SQS. + +## Setup + +### Prerequisites + +Decide if event or message based architecture is the right approach for your service, and if you have the digital +capability to support it in your organisation. +You may need queuing if your service expects high volume of submissions, but your webhook endpoints or further +downstream endpoints change frequently or have slow response times. + +You will need to set up a MySQL or PostgreSQL database. + +Use `PGBOSS` and PostgreSQL for higher availability and features like exponential backoff. +It is highly recommended you use `PGBOSS` and PostgreSQL. MYSQL may be deprecated due to the additional overhead and +support that is required. + +#### PGBOSS Prerequisites + +- PostgreSQL database >=v11 +- A worker process which can connect to the PostgreSQL database, via PgBoss. Your implementation should look something like this + +```ts +export async function setupWorker() { + const pgboss = new PgBoss(config.get("Queue.url")); + await consumer.work( + "submission", + { newJobCheckInterval: 500 }, + submitHandler + ); +} + +setupWorker(); + +/** + * When a "submission" event is detected, this worker POSTs the data to `job.data.data.webhook_url` + * The source of this event is the runner, after a user has submitted a form. + */ +export async function submitHandler(job: Job) { + const { data } = job; + const requestBody = data.data; + const url = data.webhook_url; + try { + const res = await axios.post(url, requestBody); + const reference = res.data.reference; + if (reference) { + return { reference }; + } + } catch (e: any) { + throw e; + } +} +``` + +When using pgboss, it is important that successful work returns `{ reference }` so that the runner can retrieve the successful response. Thrown errors will be recorded in the database for you to investigate later. Logging has been omitted for brevity, but you should include it! + +- The `jobId` is generated when a users' submission is successfully inserted into the queue +- The webhook endpoint should respond with application/json `{ "reference": "FCDO-3252" }` + +#### MYSQL Prerequisites + +- MySQL database + +### Environment variables + +| Variable name | Definition | Default | Example | +| ------------------------------ | ---------------------------------------------------------------------------------------- | ------- | ------------------------------------------- | +| ENABLE_QUEUE_SERVICE | Whether the queue service is enabled or not | `false` | | +| QUEUE_DATABASE_TYPE | PGBOSS or MYSQL | | | +| QUEUE_DATABASE_URL | Used for configuring the endpoint of the database instance | | mysql://username:password@endpoint/database | +| QUEUE_DATABASE_USERNAME | Used for configuring the user being used to access the database | | root | +| QUEUE_DATABASE_PASSWORD | Used for configuring the password used for accessing the database | | password | +| QUEUE_SERVICE_POLLING_INTERVAL | The amount of time, in milliseconds, between poll requests for updates from the database | 500 | | +| QUEUE_SERVICE_POLLING_TIMEOUT | The total amount of time, in milliseconds, to poll requests for from the database | 2000 | | + +Webhooks can be configured so that the submitter only attempts to post to the webhook URL once. + +```.json +{ + "outputs": [ + { + "type": "webhook", + "name": "api", + "outputConfiguration": { + "url":"“https://api:9000", + "allowRetry": false + } + } + ] +} +``` + +## Running locally + +To use the submission queue locally, you will need to have a running instance of a database, the runner, and the +submitter. The easiest way to do this is by using the provided `docker-compose.yml` file. + +In that file, you will see the following lines commented out: + +```yaml +# - ENABLE_QUEUE_SERVICE=true +# - QUEUE_DATABASE_URL=mysql://root:root@mysql:3306/queue +# - DEBUG="prisma*" +``` + +```yaml +# if using MYSQL, uncomment submitter +# submitter: +# image: digital-form-builder-submitter +# build: +# context: . +# dockerfile: ./submitter/Dockerfile +# ports: +# - "9000:9000" +# environment: +# - PORT=9000 +# - QUEUE_DATABASE_URL=mysql://root:root@mysql:3306/queue +# - QUEUE_POLLING_INTERVAL=5000 +# - DEBUG="prisma*" +# command: yarn submitter start +# depends_on: +# mysql: +# condition: service_healthy +# mysql: +# container_name: mysql +# image: "mysql:latest" +# command: --default-authentication-plugin=mysql_native_password +# ports: +# - "3306:3306" +# environment: +# MYSQL_ROOT_PASSWORD: root +# MYSQL_DATABASE: queue +# healthcheck: +# test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] +# timeout: 20s +# retries: 10 + +# use psql if you want a PostgreSQL based queue (recommended) +# postgres: +# container_name: postgres +# image: "postgres:16" +# ports: +# - "5432:5432" +# environment: +# POSTGRES_DB: queue +# POSTGRES_PASSWORD: root +# POSTGRES_USER: user +``` + +Uncommenting the environment variables under the runner configuration will enable the queue service, set the database +url to the url of your mysql container, and turn on debug messages for prisma (the ORM used to communicate with the +database). +Uncommenting the mysql dependency will make sure the mysql server is started before prisma starts trying to connect to +it. +Uncommenting the submitter configuration will trigger the submitter to be created, exposed on port 9000, connecting to +the mysql container, with a polling interval of 5 seconds. + +Once your docker-compose file is ready, start all of your containers by using the command `docker compose up` +or `docker compose up -d` to run the containers in detached mode. + +## Error codes + +If sending the form submission to the queue, or polling the database for the form reference, is not successful the +following errors will be thrown: + +| Tags | Example | +| ------------------------------------------- | ----------------------------------------------------------------- | +| QueueStatusService, outputRequests | There was an issue sending the submission to the submission queue | +| QueueService, pollForRef, Row ref: [row_id] | Submission row not found | + +## Migration guide + +If you are moving from MYSQL to PGBOSS, ensure you have a worker which will handle the jobs added to your queue. For "zero downtime", + +1. Set up any new infrastructure components if necessary (e.g. database and worker) +1. Point the runner to the new components via `QUEUE_DATABASE_URL` +1. Keep the MySQL database as well as the submitter running. Do not delete these yet +1. Deploy new infrastructure components alongside the existing components + +Any submissions that have previously failed, or were submitted during deployment, can continue to run and submit to your webhook endpoints. +Check the database to ensure that there are no more failed entries. + +You may then safely remove the submitter, and MySQL database (if it is not used for any other purpose). diff --git a/docs/runner/templating.md b/docs/runner/templating.md new file mode 100644 index 0000000000..92c0ea7eba --- /dev/null +++ b/docs/runner/templating.md @@ -0,0 +1,29 @@ +# Templating + +If you find yourself faced with a form with content that needs to appear based on different field values, and there's a lot of options to choose from, putting all of this content in conditions may be at best very time-consuming, and at worst crash your form runner. + +With this in mind, templating may be a good solution for you. + +## How it works + +To enable templating on your form runner, you will need to set the environment variable `ALLOW_USER_TEMPLATES=true`. + +After this, you can allow fields to be exposed to the additional context by adding an `exposeToContext` flag to the component's options. This can be done manually, or through the "expose to context" field in the designer. + +Once your field is exposed, the field can be used with additional context by adding the field in titles and html components, using nunjucks templating syntax, `{{ fieldName }}`. + +You can also access additional values (such as custom html) using the additional context file. This will allow you to populate different variables based of the same user input, and these can be accessed using the format `{{ additionalContexts.contextName[fieldName].variableName }}`. + +There is an example form set up to demonstrate this. If you start your runner, and navigate to http://localhost:3009/html-templating-example, you can choose either option and see the dynamic content on the next page. + +## Adding your own additional context + +You can add your own additional context by modifying the additional context json, located at `runner/src/server/templates/additionalContexts.json`. + +Once you have added your own additionalContext namespace in the same way the example namespace is populated, you'll be able to add your context variables to your html component as stated above e.g for the example form, the list is set by referencing `{{ additionalContexts.example[contentToDisplay].listItems }}`. + +### Using nunjucks filters + +You can also apply nunjucks filters to your context, for example you may want to add the `safe` filter for printing html content. You would do this the same way you would in normal nunjucks, e.g. `{{ additionalContexts.example[contentToDisplay].listItems | safe }}`. + +For more information about what nunjucks filters are available to you, [visit the Nunjucks docs](https://mozilla.github.io/nunjucks/templating.html#filters). diff --git a/e2e/cypress/e2e/designer/notifyOutput.feature b/e2e/cypress/e2e/designer/notifyOutput.feature new file mode 100644 index 0000000000..4b28ffbdf2 --- /dev/null +++ b/e2e/cypress/e2e/designer/notifyOutput.feature @@ -0,0 +1,16 @@ +Feature: Notify output allows lists + As a user + I want to add a notify output + So that I can send emails to customers using values from the form submission + + Background: + Given the form "notifyOutput" exists + When I am viewing the designer at "/app/designer/notifyOutput" + Then The list "New list" should exist + + Scenario: Create GOVNotify output + When I open Outputs + * I choose Add output + * I use the GOVUK notify output type + * I add a personalisation + Then "New list (List)" should appear in the Description dropdown diff --git a/e2e/cypress/e2e/designer/notifyOutput.js b/e2e/cypress/e2e/designer/notifyOutput.js new file mode 100644 index 0000000000..d8ff4eecaa --- /dev/null +++ b/e2e/cypress/e2e/designer/notifyOutput.js @@ -0,0 +1,27 @@ +import { When, Then } from "@badeball/cypress-cucumber-preprocessor"; + +Then("The list {string} should exist", (listName) => { + cy.findByTestId("menu-lists").click(); + cy.findAllByTestId("edit-list").findByText(listName); + cy.findByText("Close").click(); +}); + +When("I open Outputs", () => { + cy.findByTestId("menu-outputs").click(); +}); + +When("I choose Add output", () => { + cy.findByTestId("add-output").click(); +}); + +When("I use the GOVUK notify output type", () => { + cy.findByLabelText("Output type").select("Email via GOVUK Notify"); +}); + +When("I add a personalisation", () => { + cy.findByTestId("add-notify-personalisation").click(); +}); + +Then("{string} should appear in the Description dropdown", (string) => { + cy.contains('[id="link-source"] option', string); +}); diff --git a/e2e/cypress/e2e/runner/backLinkFallback.feature b/e2e/cypress/e2e/runner/backLinkFallback.feature new file mode 100644 index 0000000000..cdf1a6ed46 --- /dev/null +++ b/e2e/cypress/e2e/runner/backLinkFallback.feature @@ -0,0 +1,20 @@ +Feature: Back link fallback + As a service team, + I want to be able to configure a back link fallback, + so that there is seamless integration between my different services + + As a user, + I want to click the back link, + so that I can return to the previous page or service. + + Scenario: Back link is displayed when there is no history + Given the form "backLinkFallback" exists + When I navigate to the "backLinkFallback" form + Then The back link href is "/help/cookies" + + Scenario: Back link fallback is not used if there is session history + Given the form "backLinkFallback" exists + When I navigate to the "backLinkFallback" form + Then The back link href is "/help/cookies" + When I continue + Then The back link href is "/backLinkFallback/start" \ No newline at end of file diff --git a/e2e/cypress/e2e/runner/backLinkFallback.js b/e2e/cypress/e2e/runner/backLinkFallback.js new file mode 100644 index 0000000000..cdd21c3ed7 --- /dev/null +++ b/e2e/cypress/e2e/runner/backLinkFallback.js @@ -0,0 +1,7 @@ +import { When, Then } from "@badeball/cypress-cucumber-preprocessor"; + +Then("The back link href is {string}", (href) => { + cy.findByRole("link", { name: "Back" }) + .should("have.attr", "href") + .and("eq", href); +}); diff --git a/e2e/cypress/e2e/runner/completeAForm.feature b/e2e/cypress/e2e/runner/completeAForm.feature index f7cb81250c..66f0f777d3 100644 --- a/e2e/cypress/e2e/runner/completeAForm.feature +++ b/e2e/cypress/e2e/runner/completeAForm.feature @@ -11,7 +11,7 @@ Feature: Complete a form * I continue * I choose "No, I don't have evidence" * I continue - * I enter "the additional info" for "Additional Info (Optional)" + * I enter "the additional info" for "Additional Info (optional)" * I continue Then I see a summary list with the values | title | value | @@ -51,7 +51,7 @@ Feature: Complete a form * I enter "jen+forms@cautionyourblast.com" for "Email address field" * I enter "123" for "Telephone number field" * I enter "line 1" for "Address line 1" - * I enter "line 2" for "Address line 2 (Optional)" + * I enter "line 2" for "Address line 2 (optional)" * I enter "London" for "Town or city" * I enter "ec2a4ps" for "Postcode" * I choose "Sole trader" @@ -67,7 +67,7 @@ Feature: Complete a form When I choose "Yes" And I continue * I enter "line 1" for "Address line 1" - * I enter "line 2" for "Address line 2 (Optional)" + * I enter "line 2" for "Address line 2 (optional)" * I enter "London" for "Town or city" * I enter "ec2a4ps" for "Postcode" * I enter "2025-12-25" for "What date was the vehicle registered at this address?" @@ -93,5 +93,5 @@ Feature: Complete a form Scenario: Error messages are displayed and can be resolved Given I navigate to the "report-a-terrorist" form When I continue - Then I see the error "Do you have a link to the material? is required" for "Do you have a link to the material?" + Then I see the error "Select do you have a link to the material?" for "Do you have a link to the material?" diff --git a/e2e/cypress/e2e/runner/dateValidation.feature b/e2e/cypress/e2e/runner/dateValidation.feature new file mode 100644 index 0000000000..6f7afd3784 --- /dev/null +++ b/e2e/cypress/e2e/runner/dateValidation.feature @@ -0,0 +1,35 @@ +Feature: Date validation + + Background: + Given the form "date" exists + + Scenario: Errors appear for missing date parts + When I navigate to the "date" form + When I enter the day "25" for "maxFiveDaysInFuture" + And I continue + Then I see the error "Enter a date at most 5 days in the future must include a month" for "Enter a date at most 5 days in the future" + When I enter the month "12" for "maxFiveDaysInFuture" + And I continue + Then I see the error "Enter a date at most 5 days in the future must include a year" for "Enter a date at most 5 days in the future" + When I enter the year "2000" for "maxFiveDaysInFuture" + And I continue + Then I don't see "Enter a date at most 5 days in the future must include a year" + + Scenario: Errors appear for invalid date parts + When I navigate to the "date" form + When I enter the day "50" for "maxFiveDaysInFuture" + When I enter the month "30" for "maxFiveDaysInFuture" + When I enter the year "1" for "maxFiveDaysInFuture" + And I continue + Then I see the date parts error "day must be between 1 and 31" + Then I see the date parts error "month must be between 1 and 12" + Then I see the date parts error "year must be 1000 or higher" + + + Scenario: Errors appear for max days in future and max days in past + When I navigate to the "date" form + When I enter a date 30 days in the future for "maxFiveDaysInFuture" + When I enter a date 30 days in the past for "maxFiveDaysInPast" + And I continue + Then I see the date parts with a partial error string "enter a date at most 5 days in the future must be the same as or before" for "maxFiveDaysInFuture" + Then I see the date parts with a partial error string "enter a date at most 5 days in the past must be the same as or after" for "maxFiveDaysInPast" diff --git a/e2e/cypress/e2e/runner/dateValidation.js b/e2e/cypress/e2e/runner/dateValidation.js new file mode 100644 index 0000000000..d0080e1a78 --- /dev/null +++ b/e2e/cypress/e2e/runner/dateValidation.js @@ -0,0 +1,78 @@ +import { When, Then } from "@badeball/cypress-cucumber-preprocessor"; + +When("I enter the day {string} for {string}", (day, fieldName) => { + cy.get(`#${fieldName}`).within(() => { + cy.findByLabelText("Day").type(day); + }); +}); + +When("I enter the month {string} for {string}", (month, fieldName) => { + cy.get(`#${fieldName}`).within(() => { + cy.findByLabelText("Month").type(month); + }); +}); + +When("I enter the year {string} for {string}", (year, fieldName) => { + cy.get(`#${fieldName}`).within(() => { + cy.findByLabelText("Year").type(year); + }); +}); + +Then("I see the date parts error {string}", (error) => { + /** + * Date parts only show one error at a time. + */ + cy.findByText("Fix the following errors"); + cy.findByRole("link", { name: error, exact: false }); +}); + +Then( + "I see the date parts with a partial error string {string} for {string}", + (error, fieldName) => { + /** + * Date parts only show one error at a time. + */ + cy.findByText("Fix the following errors"); + cy.get(`#${fieldName}-error`).within(() => { + cy.findByText(error, { exact: false }); + }); + } +); + +When( + "I enter a date {int} days in the future for {string}", + (days, fieldName) => { + const today = new Date(); + const date = new Date(today); + date.setDate(today.getDate() + days); + + const day = date.getDate(); + const month = date.getMonth() + 1; + const year = date.getFullYear(); + + cy.get(`#${fieldName}`).within(() => { + cy.findByLabelText("Day").type(day); + cy.findByLabelText("Month").type(month); + cy.findByLabelText("Year").type(year); + }); + } +); + +When( + "I enter a date {int} days in the past for {string}", + (days, fieldName) => { + const today = new Date(); + const date = new Date(today); + date.setDate(today.getDate() - days); + + const day = date.getDate(); + const month = date.getMonth() + 1; + const year = date.getFullYear(); + + cy.get(`#${fieldName}`).within(() => { + cy.findByLabelText("Day").type(day); + cy.findByLabelText("Month").type(month); + cy.findByLabelText("Year").type(year); + }); + } +); diff --git a/e2e/cypress/e2e/runner/htmlTemplating.feature b/e2e/cypress/e2e/runner/htmlTemplating.feature new file mode 100644 index 0000000000..5f9d53282c --- /dev/null +++ b/e2e/cypress/e2e/runner/htmlTemplating.feature @@ -0,0 +1,17 @@ +Feature: HTML templating in forms + + Scenario: Correct content should be shown for option one + When the form "html-templating-example" exists + And I choose "Answer 1" + And I continue + Then I see "This content is based on answer 1" + And I see "Item 1" + And I see "Item 2" + + Scenario: Correct content should be shown for option 2 + When the form "html-templating-example" exists + And I choose "Answer 2" + And I continue + Then I see "This content is based on answer 2" + And I see "Item 3" + And I see "Item 4" \ No newline at end of file diff --git a/e2e/cypress/e2e/runner/imageQualityPlayback.feature b/e2e/cypress/e2e/runner/imageQualityPlayback.feature new file mode 100644 index 0000000000..328b0fc04a --- /dev/null +++ b/e2e/cypress/e2e/runner/imageQualityPlayback.feature @@ -0,0 +1,24 @@ +Feature: Image quality playback page + + Background: + Given the form "image-quality-playback" exists + + Scenario Outline: Handling upload + Given I navigate to the "image-quality-playback" form + When I upload a file that "" + Then I see the heading "" + Examples: + | case | heading | + | fails-ocr | Check your image | + | passes | Summary | + + Scenario Outline: Navigating away from the playback page + Given I navigate to the "image-quality-playback" form + When I upload a file that "fails-ocr" + And I choose "