+
);
diff --git a/designer/client/i18n/translations/en.translation.json b/designer/client/i18n/translations/en.translation.json
index b6e13fffe5..1657fcf028 100644
--- a/designer/client/i18n/translations/en.translation.json
+++ b/designer/client/i18n/translations/en.translation.json
@@ -208,6 +208,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": {
diff --git a/designer/client/reducers/component/componentReducer.options.ts b/designer/client/reducers/component/componentReducer.options.ts
index 8b555af45b..b3046945d6 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: {
diff --git a/designer/client/reducers/component/types.ts b/designer/client/reducers/component/types.ts
index b3c2c9f08f..a1e14657e3 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",
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/model/src/components/types.ts b/model/src/components/types.ts
index 3121a5e1d4..fcb283b0aa 100644
--- a/model/src/components/types.ts
+++ b/model/src/components/types.ts
@@ -217,6 +217,7 @@ export interface FileUploadFieldComponent {
multiple?: boolean;
classes?: string;
exposeToContext?: boolean;
+ imageQualityPlayback?: boolean;
};
schema: {};
}
diff --git a/runner/src/server/plugins/engine/pageControllers/PageController.ts b/runner/src/server/plugins/engine/pageControllers/PageController.ts
index 78bd0a0f6c..ccb871b005 100644
--- a/runner/src/server/plugins/engine/pageControllers/PageController.ts
+++ b/runner/src/server/plugins/engine/pageControllers/PageController.ts
@@ -40,7 +40,7 @@ export class PageController extends PageControllerBase {
onPreHandler: {
method: async (request: HapiRequest, h: HapiResponseToolkit) => {
const { uploadService } = request.services([]);
- return uploadService.handleUploadRequest(request, h);
+ return uploadService.handleUploadRequest(request, h, this.pageDef);
},
},
onPostHandler: {
diff --git a/runner/src/server/plugins/engine/pageControllers/PlaybackUploadPageController.ts b/runner/src/server/plugins/engine/pageControllers/PlaybackUploadPageController.ts
new file mode 100644
index 0000000000..b011a4fbc0
--- /dev/null
+++ b/runner/src/server/plugins/engine/pageControllers/PlaybackUploadPageController.ts
@@ -0,0 +1,109 @@
+import { PageController } from "server/plugins/engine/pageControllers/PageController";
+import { FormModel } from "server/plugins/engine/models";
+import { Page } from "@xgovformbuilder/model";
+import { FormComponent } from "server/plugins/engine/components";
+import { HapiRequest, HapiResponseToolkit } from "server/types";
+import joi from "joi";
+import { FormSubmissionErrors } from "../types";
+export class PlaybackUploadPageController extends PageController {
+ inputComponent: FormComponent;
+ retryUploadViewModel = {
+ name: "retryUpload",
+ type: "RadiosField",
+ options: {},
+ schema: {},
+ fieldset: {
+ legend: {
+ text: "Would you like to upload a new image?",
+ isPageHeading: false,
+ classes: "govuk-fieldset__legend--s",
+ },
+ },
+ items: [
+ {
+ value: true,
+ text: "Yes - I would like to upload a new image",
+ },
+ {
+ value: false,
+ text: "No - I am happy with the image",
+ },
+ ],
+ };
+
+ constructor(model: FormModel, pageDef: Page, inputComponent: FormComponent) {
+ super(model, pageDef);
+ this.inputComponent = inputComponent;
+ this.formSchema = joi.object({
+ crumb: joi.string(),
+ retryUpload: joi
+ .string()
+ .required()
+ .allow("true", "false")
+ .label("if you would like to upload a new image"),
+ });
+ }
+
+ /**
+ * Gets the radio button view model for the "Would you like to upload a new image?" question
+ * @param error - if the user hasn't chosen an option and tries to continue, add the required field error to the field
+ * @returns the view model for the radio button component
+ * */
+ getRetryUploadViewModel(errors?: FormSubmissionErrors) {
+ let viewModel = { ...this.retryUploadViewModel };
+ errors?.errorList?.forEach((err) => {
+ if (err.name === viewModel.name) {
+ viewModel.errorMessage = {
+ text: err.text,
+ };
+ }
+ });
+ return viewModel;
+ }
+
+ makeGetRouteHandler() {
+ return async (request: HapiRequest, h: HapiResponseToolkit) => {
+ const { cacheService } = request.services([]);
+
+ const state = await cacheService.getState(request);
+ const { progress = [] } = state;
+ let sectionTitle = this.section?.title;
+ return h.view("upload-playback", {
+ sectionTitle: sectionTitle,
+ showTitle: true,
+ pageTitle: "Check your image",
+ backLink: progress[progress.length - 1] ?? this.backLinkFallback,
+ radios: this.getRetryUploadViewModel(),
+ });
+ };
+ }
+
+ makePostRouteHandler() {
+ return async (request: HapiRequest, h: HapiResponseToolkit) => {
+ const { cacheService } = request.services([]);
+
+ const state = await cacheService.getState(request);
+ const { progress = [] } = state;
+ const { payload } = request;
+ const result = this.formSchema.validate(payload, this.validationOptions);
+ if (result.error) {
+ const errors = this.getErrors(result);
+ let sectionTitle = this.section?.title;
+ return h.view("upload-playback", {
+ sectionTitle: sectionTitle,
+ showTitle: true,
+ pageTitle: "Check your image",
+ uploadErrors: errors,
+ backLink: progress[progress.length - 2] ?? this.backLinkFallback,
+ radios: this.getRetryUploadViewModel(errors),
+ });
+ }
+
+ if (payload.retryUpload === "true") {
+ return h.redirect(`/${this.model.basePath}${this.path}`);
+ }
+
+ return h.redirect(this.getNext(request.payload));
+ };
+ }
+}
diff --git a/runner/src/server/plugins/engine/pageControllers/UploadPageController.ts b/runner/src/server/plugins/engine/pageControllers/UploadPageController.ts
new file mode 100644
index 0000000000..09b0c8b217
--- /dev/null
+++ b/runner/src/server/plugins/engine/pageControllers/UploadPageController.ts
@@ -0,0 +1,60 @@
+import { PageController } from "server/plugins/engine/pageControllers/PageController";
+import { FormModel } from "server/plugins/engine/models";
+import { HapiRequest, HapiResponseToolkit } from "server/types";
+import { PlaybackUploadPageController } from "server/plugins/engine/pageControllers/PlaybackUploadPageController";
+import { FormComponent } from "server/plugins/engine/components";
+
+function isUploadField(component: FormComponent) {
+ return component.type === "FileUploadField";
+}
+
+export class UploadPageController extends PageController {
+ playback: PlaybackUploadPageController;
+ inputComponent: FormComponent;
+ constructor(model: FormModel, pageDef: any) {
+ super(model, pageDef);
+ const inputComponent = this.components?.items?.find(isUploadField);
+ if (!inputComponent) {
+ throw Error(
+ "UploadPageController initialisation failed, no file upload component was found"
+ );
+ }
+ this.playback = new PlaybackUploadPageController(
+ model,
+ pageDef,
+ inputComponent as FormComponent
+ );
+ this.inputComponent = inputComponent as FormComponent;
+ }
+
+ makeGetRouteHandler() {
+ return async (request: HapiRequest, h: HapiResponseToolkit) => {
+ const { query } = request;
+ const { view } = query;
+
+ if (view === "playback") {
+ return this.playback.makeGetRouteHandler()(request, h);
+ }
+
+ return super.makeGetRouteHandler()(request, h);
+ };
+ }
+
+ makePostRouteHandler() {
+ return async (request: HapiRequest, h: HapiResponseToolkit) => {
+ const { query } = request;
+
+ if (query?.view === "playback") {
+ return this.playback.makePostRouteHandler()(request, h);
+ }
+
+ const defaultRes = super.makePostRouteHandler()(request, h);
+
+ if (request.pre?.warning) {
+ return h.redirect("?view=playback");
+ }
+
+ return defaultRes;
+ };
+ }
+}
diff --git a/runner/src/server/plugins/engine/pageControllers/helpers.ts b/runner/src/server/plugins/engine/pageControllers/helpers.ts
index 74b48293fb..5659d700ad 100644
--- a/runner/src/server/plugins/engine/pageControllers/helpers.ts
+++ b/runner/src/server/plugins/engine/pageControllers/helpers.ts
@@ -9,6 +9,7 @@ import { SummaryPageController } from "./SummaryPageController";
import { PageControllerBase } from "./PageControllerBase";
import { RepeatingFieldPageController } from "./RepeatingFieldPageController";
import { Page } from "@xgovformbuilder/model";
+import { UploadPageController } from "server/plugins/engine/pageControllers/UploadPageController";
const PageControllers = {
DobPageController,
@@ -19,6 +20,7 @@ const PageControllers = {
SummaryPageController,
PageControllerBase,
RepeatingFieldPageController,
+ UploadPageController,
};
export const controllerNameFromPath = (filePath: string) => {
diff --git a/runner/src/server/plugins/engine/plugin.ts b/runner/src/server/plugins/engine/plugin.ts
index dc6a4abb47..375b273231 100644
--- a/runner/src/server/plugins/engine/plugin.ts
+++ b/runner/src/server/plugins/engine/plugin.ts
@@ -229,7 +229,12 @@ export const plugin = {
const { uploadService } = server.services([]);
const handleFiles = (request: HapiRequest, h: HapiResponseToolkit) => {
- return uploadService.handleUploadRequest(request, h);
+ const { path, id } = request.params;
+ const model = forms[id];
+ const page = model?.pages.find(
+ (page) => normalisePath(page.path) === normalisePath(path)
+ );
+ return uploadService.handleUploadRequest(request, h, page.pageDef);
};
const postHandler = async (
diff --git a/runner/src/server/services/uploadService.ts b/runner/src/server/services/uploadService.ts
index 299ba6f446..53331efbd2 100644
--- a/runner/src/server/services/uploadService.ts
+++ b/runner/src/server/services/uploadService.ts
@@ -19,7 +19,6 @@ const ERRORS = {
fileSizeError: 'The selected file for "%s" is too large',
fileTypeError: "Invalid file type. Upload a PNG, JPG or PDF",
virusError: 'The selected file for "%s" contained a virus',
- qualityError: 'The selected file for "%s" was too blurry',
default: "There was an error uploading your file",
};
@@ -72,7 +71,7 @@ export class UploadService {
}
parsedDocumentUploadResponse({ res, payload }) {
- const errorCodeFromApi = payload?.toString?.();
+ const warning = payload?.toString?.();
let error: string | undefined;
let location: string | undefined;
switch (res.statusCode) {
@@ -86,7 +85,7 @@ export class UploadService {
error = ERRORS.fileSizeError;
break;
case 422:
- error = ERRORS[errorCodeFromApi] ?? ERRORS.virusError;
+ error = ERRORS.virusError;
break;
default:
error = ERRORS.default;
@@ -95,6 +94,7 @@ export class UploadService {
return {
location,
error,
+ warning,
};
}
@@ -103,7 +103,11 @@ export class UploadService {
return h.continue;
}
- async handleUploadRequest(request: HapiRequest, h: HapiResponseToolkit) {
+ async handleUploadRequest(
+ request: HapiRequest,
+ h: HapiResponseToolkit,
+ page: any
+ ) {
const { cacheService } = request.services([]);
const state = await cacheService.getState(request);
const originalFilenames = state?.originalFilenames ?? {};
@@ -181,10 +185,13 @@ export class UploadService {
if (validFiles.length === values.length) {
try {
- const { error, location } = await this.uploadDocuments(validFiles);
+ const { error, location, warning } = await this.uploadDocuments(
+ validFiles
+ );
if (location) {
originalFilenames[key] = { location };
request.payload[key] = location;
+ request.pre.warning = warning;
}
if (error) {
request.pre.errors = [
diff --git a/runner/src/server/views/upload-playback.html b/runner/src/server/views/upload-playback.html
new file mode 100644
index 0000000000..7c15d404ca
--- /dev/null
+++ b/runner/src/server/views/upload-playback.html
@@ -0,0 +1,53 @@
+{% from "error-summary/macro.njk" import govukErrorSummary %}
+{% from "notification-banner/macro.njk" import govukNotificationBanner %}
+{% from "details/macro.njk" import govukDetails %}
+{% from "radios/macro.njk" import govukRadios %}
+{% from "button/macro.njk" import govukButton %}
+
+{% extends "layout.html" %}
+
+{% set infoHtml %}
+
+ It looks like your image is too blurred. Upload a new one or if you're happy with it you can still submit it.
+ It will be checked by consular staff. If they cannot read it they will contact you - this could delay your application.
+
+{% endset %}
+
+{% set takePhotoHtml %}
+
+
+
lay it flat on a table or for better results against a wall