Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate multiple fields #1562

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
4 changes: 2 additions & 2 deletions app/components/form/ValidatedFlowForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { StrapiFormComponents } from "~/services/cms/components/StrapiFormCompon
import type { StrapiFormComponent } from "~/services/cms/models/StrapiFormComponent";
import { splatFromParams } from "~/services/params";
import { CSRFKey } from "~/services/security/csrf/csrfKey";
import { validatorForFieldnames } from "~/services/validation/buildStepValidator";
import { validatorForFieldNames } from "~/services/validation/stepValidator/validatorForFieldNames";

type ValidatedFlowFormProps = {
stepData: Context;
Expand All @@ -25,7 +25,7 @@ function ValidatedFlowForm({
const stepId = splatFromParams(useParams());
const { pathname } = useLocation();
const fieldNames = formElements.map((entry) => entry.name);
const validator = validatorForFieldnames(fieldNames, pathname);
const validator = validatorForFieldNames(fieldNames, pathname);
return (
<ValidatedForm
id={`${stepId}_form`}
Expand Down
6 changes: 3 additions & 3 deletions app/components/form/__test__/ValidatedFlowForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import { getStrapiTextareaComponent } from "tests/factories/cmsModels/strapiText
import { getStrapiTileGroupComponent } from "tests/factories/cmsModels/strapiTileGroupComponent";
import ValidatedFlowForm from "~/components/form/ValidatedFlowForm";
import type { StrapiFormComponent } from "~/services/cms/models/StrapiFormComponent";
import * as buildStepValidator from "~/services/validation/buildStepValidator";
import { checkedRequired } from "~/services/validation/checkedCheckbox";
import { createDateSchema } from "~/services/validation/date";
import { integerSchema } from "~/services/validation/integer";
import * as validatorForFieldNames from "~/services/validation/stepValidator/validatorForFieldNames";
import { stringRequiredSchema } from "~/services/validation/stringRequired";
import { timeSchema } from "~/services/validation/time";
import {
Expand All @@ -36,8 +36,8 @@ vi.mock("~/services/params", () => ({
}));

const fieldNameValidatorSpy = vi.spyOn(
buildStepValidator,
"validatorForFieldnames",
validatorForFieldNames,
"validatorForFieldNames",
);

describe("ValidatedFlowForm", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { z } from "zod";
import { validateDepartureAfterArrival } from "../validation";

describe("validation", () => {
describe("validateDepartureAfterArrival", () => {
const baseSchema = z.object({
direktAbflugsDatum: z.string(),
direktAbflugsZeit: z.string(),
direktAnkunftsDatum: z.string(),
direktAnkunftsZeit: z.string(),
});

const validator = validateDepartureAfterArrival(baseSchema);

it("should return success false given undefined values", () => {
const result = validator.safeParse({
direktAbflugsDatum: undefined,
direktAbflugsZeit: undefined,
direktAnkunftsDatum: undefined,
direktAnkunftsZeit: undefined,
});

expect(result.success).toBe(false);
});

it("should return success false given a departure time after the arrival", () => {
const result = validator.safeParse({
direktAbflugsDatum: "01.01.2024",
direktAbflugsZeit: "14:00",
direktAnkunftsDatum: "01.01.2024",
direktAnkunftsZeit: "11:00",
});

expect(result.success).toBe(false);
});

it("should return success false given a departure date after the arrival", () => {
const result = validator.safeParse({
direktAbflugsDatum: "02.01.2024",
direktAbflugsZeit: "14:00",
direktAnkunftsDatum: "01.01.2024",
direktAnkunftsZeit: "15:00",
});

expect(result.success).toBe(false);
});

it("should return success true given a departure date before the arrival", () => {
const result = validator.safeParse({
direktAbflugsDatum: "01.01.2024",
direktAbflugsZeit: "14:00",
direktAnkunftsDatum: "02.01.2024",
direktAnkunftsZeit: "15:00",
});

expect(result.success).toBe(true);
});

it("should return success true given a departure time before the arrival", () => {
const result = validator.safeParse({
direktAbflugsDatum: "01.01.2024",
direktAbflugsZeit: "14:00",
direktAnkunftsDatum: "01.01.2024",
direktAnkunftsZeit: "15:00",
});

expect(result.success).toBe(true);
});
});
});
44 changes: 44 additions & 0 deletions app/domains/fluggastrechte/formular/services/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { z } from "zod";
import type { ValidationMultipleFieldsBaseSchema } from "~/domains/validationsMultipleFields";

// Helper function to convert German date/time format to timestamp
function convertToTimestamp(date: string, time: string): number {
const [day, month, year] = date.split(".").map(Number);
const [hours, minutes] = time.split(":").map(Number);
return new Date(year, month - 1, day, hours, minutes).getTime();
}

export function validateDepartureAfterArrival(
baseSchema: ValidationMultipleFieldsBaseSchema,
) {
return baseSchema.superRefine((data, ctx) => {
const departureDateTime = convertToTimestamp(
data.direktAbflugsDatum,
data.direktAbflugsZeit,
);

const arrivalDateTime = convertToTimestamp(
data.direktAnkunftsDatum,
data.direktAnkunftsZeit,
);

if (departureDateTime > arrivalDateTime) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "departureAfterArrival",
path: ["direktAnkunftsZeit"],
fatal: true,
});

// add new issue to invalidate this field as well
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "departureAfterArrival",
path: ["direktAnkunftsDatum"],
fatal: true,
});

return z.NEVER;
}
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { ValidationMultipleFieldsPathName } from "~/domains/validationsMultipleFields";
import { validateDepartureAfterArrival } from "./services/validation";

export const fluggastrechtValidationMultipleFields: ValidationMultipleFieldsPathName =
{
"/flugdaten/geplanter-flug": validateDepartureAfterArrival,
};
32 changes: 32 additions & 0 deletions app/domains/validationsMultipleFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { z } from "zod";
import type { FlowId } from "./flowIds";
import { fluggastrechtValidationMultipleFields } from "./fluggastrechte/formular/validationMultipleFields";

export type ValidationMultipleFieldsBaseSchema = z.ZodObject<
Record<string, z.ZodTypeAny>
>;

export type ValidationFunctionMultipleFields = (
baseSchema: ValidationMultipleFieldsBaseSchema,
) => z.ZodTypeAny;

export type ValidationMultipleFieldsPathName = Record<
string,
ValidationFunctionMultipleFields
>;

const validationMultipleFields = {
"/beratungshilfe/antrag": undefined,
"/beratungshilfe/vorabcheck": undefined,
"/geld-einklagen/vorabcheck": undefined,
"/geld-einklagen/formular": undefined,
"/fluggastrechte/vorabcheck": undefined,
"/fluggastrechte/formular": fluggastrechtValidationMultipleFields,
"/prozesskostenhilfe/formular": undefined,
} as const satisfies Record<
FlowId,
ValidationMultipleFieldsPathName | undefined
>;

export const getValidationMultipleFields = (flowId: FlowId) =>
validationMultipleFields[flowId];
2 changes: 1 addition & 1 deletion app/routes/shared/formular.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
});

// Inject heading into <legend> inside radio groups
// TODO: only do for pages with *one* select?

Check warning on line 130 in app/routes/shared/formular.server.ts

View workflow job for this annotation

GitHub Actions / code-quality / npm run lint

Complete the task associated to this "TODO" comment
const formElements = cmsContent.formContent.map((strapiFormElement) => {
if (
isStrapiSelectComponent(strapiFormElement) &&
Expand Down Expand Up @@ -239,7 +239,7 @@
}
}

const validationResult = await validateFormData(flowId, relevantFormData);
const validationResult = await validateFormData(pathname, relevantFormData);

if (validationResult.error)
return validationError(
Expand Down
2 changes: 1 addition & 1 deletion app/routes/shared/vorabcheck.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
);

// Inject heading into <legend> inside radio groups
// TODO: only do for pages with *one* select?

Check warning on line 68 in app/routes/shared/vorabcheck.server.ts

View workflow job for this annotation

GitHub Actions / code-quality / npm run lint

Complete the task associated to this "TODO" comment
const headings = contentElements.filter(isStrapiHeadingComponent);
const formElements = vorabcheckPage.form.map((strapiFormElement) => {
if (
Expand Down Expand Up @@ -132,7 +132,7 @@
const formData = await request.formData();

const relevantFormData = filterFormData(formData);
const validationResult = await validateFormData(flowId, relevantFormData);
const validationResult = await validateFormData(pathname, relevantFormData);
if (validationResult.error)
return validationError(
validationResult.error,
Expand Down
36 changes: 0 additions & 36 deletions app/services/validation/buildStepValidator.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from "zod";
import { buildStepValidator } from "~/services/validation/buildStepValidator";
import type { ValidationFunctionMultipleFields } from "~/domains/validationsMultipleFields";
import { buildStepValidator } from "~/services/validation/stepValidator/buildStepValidator";

describe("buildStepValidator", () => {
describe("nested fields", () => {
Expand Down Expand Up @@ -85,4 +86,70 @@ describe("buildStepValidator", () => {
expect((await validator.validate({})).error).toBeDefined();
});
});

describe("multiple fields validation", () => {
const schemas = {
field1: z.number(),
field2: z.number(),
};

const multipleFieldsValidation: ValidationFunctionMultipleFields = (
schemas,
) =>
schemas.refine(
({ field1, field2 }) => {
return field1 < field2;
},
{
path: ["field1"],
message: "invalid",
},
);

const fieldNames = ["field1", "field2"];

it("should return an error object given the field1 bigger than field2", async () => {
const validator = buildStepValidator(
schemas,
fieldNames,
multipleFieldsValidation,
);

const actualValidation = await validator.validate({
field1: 1,
field2: 0,
});

expect(actualValidation).toEqual(
expect.objectContaining({
error: {
fieldErrors: { field1: "invalid" },
},
}),
);
});

it("should return data object given the field1 smaller than field2", async () => {
const validator = buildStepValidator(
schemas,
fieldNames,
multipleFieldsValidation,
);

const actualValidation = await validator.validate({
field1: 1,
field2: 2,
});

expect(actualValidation).toEqual(
expect.objectContaining({
error: undefined,
data: {
field1: 1,
field2: 2,
},
}),
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type {
ValidationMultipleFieldsBaseSchema,
ValidationMultipleFieldsPathName,
} from "~/domains/validationsMultipleFields";
import { getValidationMultipleFields } from "~/domains/validationsMultipleFields";
import { getValidationMultipleFieldsByPathname } from "../getValidationMultipleFieldsByPathName";

vi.mock("~/domains/validationsMultipleFields");

describe("getValidationMultipleFieldsByPathname", () => {
it("should return undefined given a mocked getValidationMultipleFields as undefined", () => {
vi.mocked(getValidationMultipleFields).mockReturnValue(undefined);

const actual = getValidationMultipleFieldsByPathname(
"/fluggastrechte/formular/flugdaten/geplanter-flug",
);

expect(actual).toBeUndefined();
});

it("should return a value given a mocked getValidationMultipleFields", () => {
const mockValidationMultipleFields: ValidationMultipleFieldsPathName = {
"/flugdaten/geplanter-flug": (
baseSchema: ValidationMultipleFieldsBaseSchema,
) => {
return baseSchema.describe("TEST");
},
};

vi.mocked(getValidationMultipleFields).mockReturnValue(
mockValidationMultipleFields,
);

const actual = getValidationMultipleFieldsByPathname(
"/fluggastrechte/formular/flugdaten/geplanter-flug",
);

expect(actual).toEqual(
mockValidationMultipleFields["/flugdaten/geplanter-flug"],
);
});

it("should return undefined given a not exist mocked pathname getValidationMultipleFields", () => {
const mockValidationMultipleFields: ValidationMultipleFieldsPathName = {
"/flugdaten/geplanter-flug": (
baseSchema: ValidationMultipleFieldsBaseSchema,
) => {
return baseSchema.describe("TEST");
},
};

vi.mocked(getValidationMultipleFields).mockReturnValue(
mockValidationMultipleFields,
);

const actual = getValidationMultipleFieldsByPathname(
"/fluggastrechte/formular/flugdaten/tatsaechlicher-flug",
);

expect(actual).toBeUndefined();
});
});
Loading
Loading