Skip to content

Commit

Permalink
feat: Added image quality playback page for document upload
Browse files Browse the repository at this point in the history
  • Loading branch information
ziggy-cyb authored Dec 1, 2023
1 parent d443ec2 commit 0b3c792
Show file tree
Hide file tree
Showing 14 changed files with 418 additions and 13 deletions.
45 changes: 39 additions & 6 deletions designer/client/file-upload-field-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<details className="govuk-details">
Expand All @@ -22,21 +27,20 @@ export function FileUploadFieldEdit() {
<div className="govuk-checkboxes__item">
<input
className="govuk-checkboxes__input"
id="field-options.multiple"
id="field-options-multiple"
name="options.multiple"
type="checkbox"
checked={options.multiple === false}
checked={options.multiple}
onChange={(e) => {
e.preventDefault();
dispatch({
type: Actions.EDIT_OPTIONS_FILE_UPLOAD_MULTIPLE,
payload: !options.multiple,
payload: e.target.checked,
});
}}
/>
<label
className="govuk-label govuk-checkboxes__label"
htmlFor="field-options.multiple"
htmlFor="field-options-multiple"
>
{i18n("fileUploadFieldEditPage.multipleFilesOption.title")}
</label>
Expand All @@ -46,6 +50,35 @@ export function FileUploadFieldEdit() {
</div>
</div>

<div className="govuk-checkboxes govuk-form-group">
<div className="govuk-checkboxes__item">
<input
className="govuk-checkboxes__input"
id="field-options-imageQualityPlayback"
name="options.imageQualityPlayback"
type="checkbox"
checked={options.imageQualityPlayback}
onChange={(e) => {
dispatch({
type: Actions.EDIT_OPTIONS_IMAGE_QUALITY_PLAYBACK,
payload: e.target.checked,
});
}}
/>
<label
className="govuk-label govuk-checkboxes__label"
htmlFor="field-options.multiple"
>
{i18n("fileUploadFieldEditPage.imageQualityPlaybackOption.title")}
</label>
<span className="govuk-hint govuk-checkboxes__hint">
{i18n(
"fileUploadFieldEditPage.imageQualityPlaybackOption.helpText"
)}
</span>
</div>
</div>

<CssClasses />
</details>
);
Expand Down
4 changes: 4 additions & 0 deletions designer/client/i18n/translations/en.translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions designer/client/reducers/component/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions docs/runner/document-upload.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions model/src/components/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export interface FileUploadFieldComponent {
multiple?: boolean;
classes?: string;
exposeToContext?: boolean;
imageQualityPlayback?: boolean;
};
schema: {};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
};
}
}
Original file line number Diff line number Diff line change
@@ -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;
};
}
}
2 changes: 2 additions & 0 deletions runner/src/server/plugins/engine/pageControllers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,6 +20,7 @@ const PageControllers = {
SummaryPageController,
PageControllerBase,
RepeatingFieldPageController,
UploadPageController,
};

export const controllerNameFromPath = (filePath: string) => {
Expand Down
7 changes: 6 additions & 1 deletion runner/src/server/plugins/engine/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Loading

0 comments on commit 0b3c792

Please sign in to comment.