Skip to content

Commit

Permalink
feat: Allow optional fields in List and Page schemas (#4166)
Browse files Browse the repository at this point in the history
  • Loading branch information
DafyddLlyr authored Jan 27, 2025
1 parent 00dde47 commit 26f732e
Show file tree
Hide file tree
Showing 23 changed files with 406 additions and 156 deletions.
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Grid from "@mui/material/Grid";
import { visuallyHidden } from "@mui/utils";
import {
checklistInputValidationSchema,
ChecklistLayout,
checklistValidationSchema,
} from "@planx/components/Checklist/model";
import { Option } from "@planx/components/shared";
import Card from "@planx/components/shared/Preview/Card";
Expand Down Expand Up @@ -47,7 +47,7 @@ export const Checklist: React.FC<PublicChecklistProps> = (props) => {
validateOnBlur: false,
validateOnChange: false,
validationSchema: object({
checked: checklistValidationSchema(props),
checked: checklistInputValidationSchema({ data: props, required: true }),
}),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import { visuallyHidden } from "@mui/utils";
import {
checklistInputValidationSchema,
ChecklistLayout,
checklistValidationSchema,
} from "@planx/components/Checklist/model";
import Card from "@planx/components/shared/Preview/Card";
import { CardHeader } from "@planx/components/shared/Preview/CardHeader/CardHeader";
Expand Down Expand Up @@ -46,7 +46,7 @@ export const GroupedChecklist: React.FC<PublicChecklistProps> = (props) => {
validateOnBlur: false,
validateOnChange: false,
validationSchema: object({
checked: checklistValidationSchema(props),
checked: checklistInputValidationSchema({ data: props, required: true }),
}),
});

Expand Down
30 changes: 30 additions & 0 deletions editor.planx.uk/src/@planx/components/Checklist/model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { checklistInputValidationSchema } from "./model";

describe("Checklist - validation", () => {
describe("optional checklist fields in schema", () => {
const validationSchema = checklistInputValidationSchema({
data: {
options: [
{ id: "test1", data: { text: "Test 1", val: "test1" } },
{ id: "test2", data: { text: "Test 2", val: "test2" } },
],
allRequired: true,
},
required: false,
});

it("does not validate fields without a value", async () => {
const undefinedResult = await validationSchema.validate(undefined);
expect(undefinedResult).toBeUndefined();

const arrayResult = await validationSchema.validate([]);
expect(arrayResult).toHaveLength(0);
});

it("validates optional fields with a value", async () => {
await expect(() => validationSchema.validate(["test1"])).rejects.toThrow(
/All options must be checked/
);
});
});
});
30 changes: 15 additions & 15 deletions editor.planx.uk/src/@planx/components/Checklist/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,29 +123,29 @@ export const getLayout = ({
return ChecklistLayout.Basic;
};

export const checklistValidationSchema = ({
allRequired,
options,
groupedOptions,
}: Checklist) => {
export const checklistInputValidationSchema = ({
data: { allRequired, options, groupedOptions },
required,
}: {
// Cannot use type FieldValidationSchema<ChecklistInput> as this is a simplified representation (i.e. no groups)
data: Checklist;
required: boolean;
}) => {
const flatOptions = getFlatOptions({ options, groupedOptions });

return array()
.required()
.test({
name: "atLeastOneChecked",
message: "Select at least one option",
test: (checked?: Array<string>) => {
return Boolean(checked && checked.length > 0);
},
.when([], {
is: () => required,
then: array().min(1, "Select at least one option"),
otherwise: array().notRequired(),
})
.test({
name: "notAllChecked",
message: "All options must be checked",
test: (checked?: Array<string>) => {
if (!allRequired) {
return true;
}
if (!checked?.length) return true;
if (!allRequired) return true;

const allChecked = checked && checked.length === flatOptions.length;
return Boolean(allChecked);
},
Expand Down
4 changes: 2 additions & 2 deletions editor.planx.uk/src/@planx/components/DateInput/Public.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Box from "@mui/material/Box";
import { visuallyHidden } from "@mui/utils";
import {
DateInput,
dateValidationSchema,
dateInputValidationSchema,
paddedDate,
} from "@planx/components/DateInput/model";
import Card from "@planx/components/shared/Preview/Card";
Expand Down Expand Up @@ -30,7 +30,7 @@ const DateInputPublic: React.FC<Props> = (props) => {
validateOnBlur: false,
validateOnChange: false,
validationSchema: object({
date: dateValidationSchema(props),
date: dateInputValidationSchema({ data: props, required: true }),
}),
});

Expand Down
79 changes: 53 additions & 26 deletions editor.planx.uk/src/@planx/components/DateInput/model.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
DateInput,
dateSchema,
dateValidationSchema,
paddedDate,
parseDate,
} from "./model";
import { dateInputValidationSchema, dateSchema, paddedDate, parseDate } from "./model";

describe("parseDate helper function", () => {
it("returns a day value", () => {
Expand Down Expand Up @@ -49,20 +43,19 @@ describe("parseDate helper function", () => {

describe("dateSchema", () => {
test("basic validation", async () => {
expect(await dateSchema().isValid("2021-03-23")).toBe(true);
expect(await dateSchema().isValid("2021-23-03")).toBe(false);
expect(await dateSchema().isValid("2021-03-23")).toBe(
true,
);
expect(await dateSchema().isValid("2021-23-03")).toBe(
false,
);
});

const validate = async (date?: string) =>
await dateSchema()
.validate(date)
.catch((err) => err.errors);

it("throws an error for an undefined value (empty form)", async () => {
const errors = await validate(undefined);
expect(errors[0]).toMatch(/Date must include a day/);
});

it("throws an error for an nonsensical value", async () => {
const errors = await validate("ab-cd-efgh");
expect(errors[0]).toMatch(/Date must include a day/);
Expand Down Expand Up @@ -104,26 +97,60 @@ describe("dateSchema", () => {
});
});

describe("dateValidationSchema", () => {
describe("dateInputValidationSchema", () => {
test("basic validation", async () => {
expect(
await dateValidationSchema({
min: "1990-01-01",
max: "1999-12-31",
} as DateInput).isValid("1995-06-15"),
await dateInputValidationSchema({
data: {
title: "test",
min: "1990-01-01",
max: "1999-12-31",
},
required: true,
}).isValid("1995-06-15")
).toBe(true);
expect(
await dateValidationSchema({
min: "1990-01-01",
max: "1999-12-31",
} as DateInput).isValid("2021-06-15"),
await dateInputValidationSchema({
data: {
title: "test",
min: "1990-01-01",
max: "1999-12-31",
},
required: true,
}).isValid("2021-06-15"),
).toBe(false);
expect(
await dateValidationSchema({
await dateInputValidationSchema({
data: {
title: "test",
min: "1990-01-01",
max: "1999-12-31",
},
required: true,
}).isValid("1980-06-15"),
).toBe(false);
});

describe("optional fields", () => {
const validationSchema = dateInputValidationSchema({
data: {
title: "test",
min: "1990-01-01",
max: "1999-12-31",
} as DateInput).isValid("1980-06-15"),
).toBe(false);
},
required: false,
});

it("does not validate fields without a value", async () => {
const result = await validationSchema.validate(undefined);
expect(result).toBeUndefined();
});

it("validates optional fields with a value", async () => {
await expect(() =>
validationSchema.validate("2023-02-29"),
).rejects.toThrow(/Enter a valid date in DD.MM.YYYY format/);
});
});
});

Expand Down
50 changes: 35 additions & 15 deletions editor.planx.uk/src/@planx/components/DateInput/model.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { isValid, parseISO } from "date-fns";
import { object, SchemaOf, string } from "yup";
import { object, string } from "yup";

import { BaseNodeData, parseBaseNodeData } from "../shared";
import { FieldValidationSchema } from "../shared/Schema/model";

// Expected format: YYYY-MM-DD
export type UserData = string;
Expand Down Expand Up @@ -71,29 +72,41 @@ export const parseDate = (date?: string) => {
export const dateSchema = () => {
return string()
.test("missing day", "Date must include a day", (date?: string) => {
if (!date) return true;

const { day } = parseDate(date);
return day !== undefined;
})
.test("missing month", "Date must include a month", (date?: string) => {
if (!date) return true;

const { month } = parseDate(date);
return month !== undefined;
})
.test("missing year", "Date must include a year", (date?: string) => {
if (!date) return true;

const { year } = parseDate(date);
return year !== undefined;
})
.test("invalid day", "Day must be valid", (date?: string) => {
if (!date) return true;

const { day } = parseDate(date);
return Boolean(day && day <= 31);
})
.test("invalid month", "Month must be valid", (date?: string) => {
if (!date) return true;

const { month } = parseDate(date);
return Boolean(month && month <= 12);
})
.test(
"valid",
"Enter a valid date in DD.MM.YYYY format",
(date: string | undefined) => {
if (!date) return true;

// test runs regardless of required status, so don't fail it if it's undefined
return Boolean(!date || isDateValid(date));
},
Expand All @@ -103,27 +116,34 @@ export const dateSchema = () => {
/**
* Validates that date is both valid and fits within the provided min/max
*/
export const dateValidationSchema: (input: DateInput) => SchemaOf<string> = (
params,
) =>
export const dateInputValidationSchema = ({
data,
required,
}: FieldValidationSchema<DateInput>) =>
dateSchema()
.required("Enter a valid date in DD.MM.YYYY format")
.when([], {
is: () => required,
then: dateSchema().required(
"Enter a valid date in DD.MM.YYYY format"
),
otherwise: dateSchema().notRequired(),
})
.test({
name: "too soon",
message: `Enter a date later than ${
params.min && displayDate(params.min)
}`,
test: (date: string | undefined) => {
return Boolean(date && !(params.min && date < params.min));
message: `Enter a date later than ${data.min && displayDate(data.min)}`,
test: (date) => {
if (!date) return true;

return Boolean(date && !(data.min && date < data.min));
},
})
.test({
name: "too late",
message: `Enter a date earlier than ${
params.max && displayDate(params.max)
}`,
test: (date: string | undefined) => {
return Boolean(date && !(params.max && date > params.max));
message: `Enter a date earlier than ${data.max && displayDate(data.max)}`,
test: (date) => {
if (!date) return true;

return Boolean(date && !(data.max && date > data.max));
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,7 @@ describe("Form validation and error handling", () => {
)[0];

expect(dateInputErrorMessage).toHaveTextContent(
/Date must include a day/,
/Enter a valid date in DD.MM.YYYY format/,
);
});

Expand All @@ -622,7 +622,7 @@ describe("Form validation and error handling", () => {

test(
"an error displays if the minimum number of items is not met",
{ timeout: 10_000 },
{ timeout: 20_000 },
async () => {
const mockWithMinTwo = merge(cloneDeep(mockZooProps), {
schema: { min: 2 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function NumberInputComponent(props: Props): FCReturn {
},
validateOnBlur: false,
validateOnChange: false,
validationSchema: validationSchema(props),
validationSchema: validationSchema({ data: props, required: true }),
});

const inputRef = useRef<HTMLInputElement>(null);
Expand Down
25 changes: 25 additions & 0 deletions editor.planx.uk/src/@planx/components/NumberInput/model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { numberInputValidationSchema } from "./model";

describe("validation", () => {
describe("optional number fields in schema", () => {
const validationSchema = numberInputValidationSchema({
data: { title: "test", isInteger: true },
required: false,
});

it("does not validate fields without a value", async () => {
const result = await validationSchema.validate(undefined);
expect(result).toBeUndefined();
});

it("validates optional fields with a value", async () => {
await expect(() =>
validationSchema.validate("not a number")
).rejects.toThrow(/Enter a positive number/);

await expect(() => validationSchema.validate("12.34")).rejects.toThrow(
/Enter a whole number/
);
});
});
});
Loading

0 comments on commit 26f732e

Please sign in to comment.