Skip to content

Commit

Permalink
fix: allow more dynamic error messaging (#1210)
Browse files Browse the repository at this point in the history
* Added custom validation messages for file upload

* Fixed issue with MonthYear field not populating

* Fixed issue with duplicated error messages for autocomplete fields

* Added customValidationMessages option

* Updated date parts field to make sure the first related error is shown
  • Loading branch information
ziggy-cyb authored Mar 15, 2024
1 parent 8bd1aa2 commit 2112c09
Show file tree
Hide file tree
Showing 16 changed files with 235 additions and 75 deletions.
4 changes: 4 additions & 0 deletions model/src/components/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ interface TextFieldBase {
autocomplete?: string;
exposeToContext?: boolean;
disableChangingFromSummary?: boolean;
customValidationMessages?: Record<string, string>;
};
schema: {
max?: number;
Expand All @@ -103,6 +104,7 @@ interface NumberFieldBase {
suffix?: string;
exposeToContext?: boolean;
disableChangingFromSummary?: boolean;
customValidationMessages?: Record<string, string>;
};
schema: {
min?: number;
Expand All @@ -126,6 +128,7 @@ interface ListFieldBase {
exposeToContext?: boolean;
allowPrePopulation?: boolean;
disableChangingFromSummary?: boolean;
customValidationMessages?: Record<string, string>;
};
list: string;
schema: {};
Expand Down Expand Up @@ -155,6 +158,7 @@ interface DateFieldBase {
maxDaysInPast?: number;
exposeToContext?: boolean;
disableChangingFromSummary?: boolean;
customValidationMessages?: Record<string, string>;
};
schema: {};
}
Expand Down
11 changes: 11 additions & 0 deletions runner/src/server/plugins/engine/components/AutocompleteField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ import { FormSubmissionState } from "server/plugins/engine/types";
export class AutocompleteField extends SelectField {
constructor(def: ListComponentsDef, model: FormModel) {
super(def, model);

let componentSchema = this.formSchema.messages({
"any.only": "Enter {{#label}}",
});
if (def.options.customValidationMessages) {
componentSchema = componentSchema.messages(
def.options.customValidationMessages
);
}
this.formSchema = componentSchema;
this.stateSchema = componentSchema;
addClassOptionIfNone(this.options, "govuk-input--width-20");
}
getDisplayStringFromState(state: FormSubmissionState): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ export class CheckboxesField extends SelectionControlField {
constructor(def: ListComponentsDef, model: FormModel) {
super(def, model);

const { options } = def;

let schema = joi.array().single().label(def.title.toLowerCase());

if (def.options.required === false) {
if (options.required === false) {
// null or empty string is valid for optional fields
schema = schema
.empty(null)
Expand All @@ -21,6 +23,10 @@ export class CheckboxesField extends SelectionControlField {
.required();
}

if (options.customValidationMessages) {
schema = schema.messages(options.customValidationMessages);
}

this.formSchema = schema;
this.stateSchema = schema;
}
Expand Down
19 changes: 18 additions & 1 deletion runner/src/server/plugins/engine/components/DatePartsField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export class DatePartsField extends FormComponent {
required: isRequired,
optionalText: optionalText,
classes: "govuk-input--width-2",
customValidationMessages: {
"number.min": "{{#label}} must be between 1 and 31",
"number.max": "{{#label}} must be between 1 and 31",
"number.base": `${def.title} must include a day`,
},
},
hint: "",
},
Expand All @@ -48,6 +53,11 @@ export class DatePartsField extends FormComponent {
required: isRequired,
optionalText: optionalText,
classes: "govuk-input--width-2",
customValidationMessages: {
"number.min": "{{#label}} must be between 1 and 12",
"number.max": "{{#label}} must be between 1 and 12",
"number.base": `${def.title} must include a month`,
},
},
hint: "",
},
Expand All @@ -60,6 +70,9 @@ export class DatePartsField extends FormComponent {
required: isRequired,
optionalText: optionalText,
classes: "govuk-input--width-4",
customValidationMessages: {
"number.base": `${def.title} must include a year`,
},
},
hint: "",
},
Expand Down Expand Up @@ -137,7 +150,11 @@ export class DatePartsField extends FormComponent {
}
});

const firstError = errors?.errorList?.[0];
const relevantErrors =
errors?.errorList?.filter((error) => error.path.includes(this.name)) ??
[];

const firstError = relevantErrors[0];
const errorMessage = firstError && { text: firstError?.text };

return {
Expand Down
23 changes: 23 additions & 0 deletions runner/src/server/plugins/engine/components/DateTimePartsField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export class DateTimePartsField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-2",
customValidationMessages: {
"number.min": "{{#label}} must be between 1 and 31",
"number.max": "{{#label}} must be between 1 and 31",
"number.base": `${def.title} must include a day`,
},
},
},
{
Expand All @@ -42,6 +47,11 @@ export class DateTimePartsField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-2",
customValidationMessages: {
"number.min": "{{#label}} must be between 1 and 12",
"number.max": "{{#label}} must be between 1 and 12",
"number.base": `${def.title} must include a month`,
},
},
},
{
Expand All @@ -52,6 +62,9 @@ export class DateTimePartsField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-4",
customValidationMessages: {
"number.base": `${def.title} must include a year`,
},
},
},
{
Expand All @@ -62,6 +75,11 @@ export class DateTimePartsField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-2",
customValidationMessages: {
"number.min": "{{#label}} must be between 0 and 23",
"number.max": "{{#label}} must be between 0 and 23",
"number.base": `${def.title} must include an hour`,
},
},
},
{
Expand All @@ -72,6 +90,11 @@ export class DateTimePartsField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-2",
customValidationMessages: {
"number.min": "{{#label}} must be between 0 and 59",
"number.max": "{{#label}} must be between 0 and 59",
"number.base": `${def.title} must include a minute`,
},
},
},
] as any,
Expand Down
32 changes: 29 additions & 3 deletions runner/src/server/plugins/engine/components/FileUploadField.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
import { FormData, FormSubmissionErrors } from "../types";
import { FormComponent } from "./FormComponent";
import * as helpers from "./helpers";

import { DataType, ViewModel } from "./types";
import { FileUploadFieldComponent } from "@xgovformbuilder/model";
import { FormModel } from "server/plugins/engine/models";
import joi, { Schema } from "joi";

export class FileUploadField extends FormComponent {
dataType = "file" as DataType;

constructor(def: FileUploadFieldComponent, model: FormModel) {
super(def, model);

const { options = {} } = def;

let componentSchema = joi.string().label(def.title.toLowerCase());

if (options.required === false) {
componentSchema = componentSchema.allow("").allow(null);
}

componentSchema = componentSchema.messages({
"string.empty": "Upload {{#label}}",
});

if (options.customValidationMessages) {
componentSchema = componentSchema.messages(
options.customValidationMessages
);
}

this.schema = componentSchema;
}
getFormSchemaKeys() {
return helpers.getFormSchemaKeys(this.name, "string", this);
return { [this.name]: this.schema as Schema };
}

getStateSchemaKeys() {
return helpers.getStateSchemaKeys(this.name, "string", this);
return { [this.name]: this.schema as Schema };
}

get attributes() {
Expand Down
23 changes: 15 additions & 8 deletions runner/src/server/plugins/engine/components/ListFormComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,34 @@ export class ListFormComponent extends FormComponent {

constructor(def: ListComponentsDef, model: FormModel) {
super(def, model);
const { options } = def;
// @ts-ignore
this.list = model.getList(def.list);
this.listType = this.list.type ?? "string";
this.options = def.options;
this.options = options;

let schema = joi[this.listType]();
let componentSchema = joi[this.listType]();

/**
* Only allow a user to answer with values that have been defined in the list
*/
if (def.options.required === false) {
if (options.required === false) {
// null or empty string is valid for optional fields
schema = schema.empty(null).valid(...this.values, "");
componentSchema = componentSchema.empty(null).valid(...this.values, "");
} else {
schema = schema.valid(...this.values).required();
componentSchema = componentSchema.valid(...this.values).required();
}

schema = schema.label(def.title.toLowerCase());
if (options.customValidationMessages) {
componentSchema = componentSchema.messages(
options.customValidationMessages
);
}

componentSchema = componentSchema.label(def.title.toLowerCase());

this.formSchema = schema;
this.stateSchema = schema;
this.formSchema = componentSchema;
this.stateSchema = componentSchema;
}

getFormSchemaKeys() {
Expand Down
17 changes: 15 additions & 2 deletions runner/src/server/plugins/engine/components/MonthYearField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ export class MonthYearField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-2",
customValidationMessage: "{{label}} must be between 1 and 12",
customValidationMessages: {
"number.min": "{{#label}} must be between 1 and 12",
"number.max": "{{#label}} must be between 1 and 12",
"number.base": `${def.title} must include a month`,
},
},
},
{
Expand All @@ -41,6 +45,9 @@ export class MonthYearField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-4",
customValidationMessages: {
"number.base": `${def.title} must include a year`,
},
},
},
] as any,
Expand All @@ -59,7 +66,13 @@ export class MonthYearField extends FormComponent {
}

getFormDataFromState(state: FormSubmissionState) {
return this.children.getFormDataFromState(state);
const name = this.name;
const value = state[name];

return {
[`${name}__month`]: value && value[`${name}__month`],
[`${name}__year`]: value && value[`${name}__year`],
};
}

getStateValueFromValidForm(payload: FormPayload) {
Expand Down
49 changes: 27 additions & 22 deletions runner/src/server/plugins/engine/components/MultilineTextField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,44 +23,49 @@ export class MultilineTextField extends FormComponent {

constructor(def: MultilineTextFieldComponent, model: FormModel) {
super(def, model);
this.options = def.options;
this.schema = def.schema;
this.formSchema = Joi.string();
this.formSchema = this.formSchema.label(def.title.toLowerCase());
const { maxWords, customValidationMessage } = def.options;
const isRequired = def.options.required ?? true;

if (isRequired) {
this.formSchema = this.formSchema.required();
} else {
this.formSchema = this.formSchema.allow("").allow(null);
const { schema = {}, options } = def;
this.options = options;
this.schema = schema;
let componentSchema = Joi.string()
.label(def.title.toLowerCase())
.required();

if (options.required === false) {
componentSchema = componentSchema.allow("").allow(null);
}
this.formSchema = this.formSchema.ruleset;

if (def.schema.max) {
this.formSchema = this.formSchema.max(def.schema.max);
if (schema.max) {
componentSchema = componentSchema.max(schema.max);
this.isCharacterOrWordCount = true;
}

if (def.schema.min) {
this.formSchema = this.formSchema.min(def.schema.min);
if (schema.min) {
componentSchema = componentSchema.min(schema.min);
}

if (maxWords ?? false) {
this.formSchema = this.formSchema.custom((value, helpers) => {
if (inputIsOverWordCount(value, maxWords)) {
if (options.maxWords ?? false) {
componentSchema = componentSchema.custom((value, helpers) => {
if (inputIsOverWordCount(value, options.maxWords)) {
helpers.error("string.maxWords");
}
return value;
}, "max words validation");
this.isCharacterOrWordCount = true;
}

if (customValidationMessage) {
this.formSchema = this.formSchema.rule({
message: customValidationMessage,
if (options.customValidationMessage) {
componentSchema = componentSchema.rule({
message: options.customValidationMessage,
});
}

if (options.customValidationMessages) {
componentSchema = componentSchema.messages(
options.customValidationMessages
);
}

this.formSchema = componentSchema;
}

getFormSchemaKeys() {
Expand Down
Loading

0 comments on commit 2112c09

Please sign in to comment.