diff --git a/apps/ui/components/application/ApplicationEvent.tsx b/apps/ui/components/application/ApplicationEvent.tsx index 55d3cb3eb..a140e518a 100644 --- a/apps/ui/components/application/ApplicationEvent.tsx +++ b/apps/ui/components/application/ApplicationEvent.tsx @@ -107,12 +107,12 @@ function ApplicationEventInner({ | "name" | "appliedReservationsPerWeek" | "reservationUnits"; - const getTranslatedError = (field: FieldName): string => { + const getTranslatedError = (field: FieldName): string | undefined => { const error = errors.applicationSections?.[index]?.[field]; if (error?.message != null) { return t(`application:validation.${error.message}`); } - return ""; + return undefined; }; // convert from minutes to seconds (search page uses minutes, this uses seconds) @@ -121,6 +121,8 @@ function ApplicationEventInner({ value: x.value * 60, })); + const lang = getLocalizationLang(i18n.language); + return (

{t("application:Page1.basicInformationSubHeading")}

@@ -191,9 +193,9 @@ function ApplicationEventInner({ /> + {/* TODO replace with ControlledDateInput */} { clearErrors([ @@ -215,10 +217,10 @@ function ApplicationEventInner({ invalid={errors?.applicationSections?.[index]?.begin != null} errorText={getTranslatedError("begin")} /> + {/* TODO replace with ControlledDateInput */} { clearErrors([ `applicationSections.${index}.begin`, diff --git a/apps/ui/components/application/EmailInput.tsx b/apps/ui/components/application/EmailInput.tsx index 3c5909028..7fd675660 100644 --- a/apps/ui/components/application/EmailInput.tsx +++ b/apps/ui/components/application/EmailInput.tsx @@ -6,7 +6,7 @@ import { applicationErrorText } from "@/modules/util"; import type { ApplicationFormPage3Values } from "./Form"; import { SpanTwoColumns } from "./styled"; -const EmailInput = () => { +export function EmailInput() { const { t } = useTranslation(); const { @@ -43,6 +43,4 @@ const EmailInput = () => { /> ); -}; - -export { EmailInput }; +} diff --git a/apps/ui/components/application/Form.tsx b/apps/ui/components/application/Form.tsx index 9fa49efa2..b29bf57b3 100644 --- a/apps/ui/components/application/Form.tsx +++ b/apps/ui/components/application/Form.tsx @@ -78,9 +78,10 @@ const ApplicationSectionFormValueSchema = z accordionOpen: z.boolean(), // form specific: new events don't have pks and we need a unique identifier formKey: z.string(), - // selected reservation unit to show for this section - reservationUnitPk: z.number(), - priority: z.literal(200).or(z.literal(300)), + // selected reservation unit to show for this section (only used by Page2) + // TODO split the form? so we have three schemas (common + page1 + page2) + reservationUnitPk: z.number().optional(), + priority: z.literal(200).or(z.literal(300)).optional(), }) .refine((s) => s.maxDuration >= s.minDuration, { path: ["maxDuration"], @@ -164,9 +165,9 @@ function convertDate(date: string | null | undefined): string | undefined { const AddressFormValueSchema = z.object({ pk: z.number().optional(), - streetAddress: z.string(), - city: z.string(), - postCode: z.string(), + streetAddress: z.string().min(1).max(80), + city: z.string().min(1).max(80), + postCode: z.string().min(1).max(32), }); export type AddressFormValues = z.infer; @@ -174,7 +175,7 @@ export type AddressFormValues = z.infer; const OrganisationFormValuesSchema = z.object({ pk: z.number().optional(), name: z.string().min(1).max(255), - identifier: z.string().optional(), + identifier: z.string().max(255).optional(), yearEstablished: z.number().optional(), coreBusiness: z.string().min(1).max(255), address: AddressFormValueSchema, @@ -325,17 +326,126 @@ export function ApplicationFormSchemaRefined(round: { // TODO refine the form (different applicant types require different fields) // if applicantType === Organisation | Company => organisation.identifier is required // if hasBillingAddress | applicantType === Individual => billingAddress is required -const ApplicationFormPage3Schema = z.object({ - pk: z.number(), - applicantType: ApplicantTypeSchema.optional(), - organisation: OrganisationFormValuesSchema.optional(), - contactPerson: PersonFormValuesSchema.optional(), - billingAddress: AddressFormValueSchema.optional(), - // this is not submitted, we can use it to remove the billing address from submit without losing the frontend state - hasBillingAddress: z.boolean().optional(), - additionalInformation: z.string().optional(), - homeCity: z.number().optional(), -}); +export const ApplicationFormPage3Schema = z + .object({ + pk: z.number(), + applicantType: ApplicantTypeSchema.optional(), + organisation: OrganisationFormValuesSchema.optional(), + contactPerson: PersonFormValuesSchema.optional(), + billingAddress: AddressFormValueSchema.optional(), + // this is not submitted, we can use it to remove the billing address from submit without losing the frontend state + hasBillingAddress: z.boolean().optional(), + additionalInformation: z.string().optional(), + homeCity: z.number().optional(), + }) + .superRefine((val, ctx) => { + switch (val.applicantType) { + case ApplicantTypeChoice.Association: + case ApplicantTypeChoice.Company: + if (val.organisation?.identifier == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["organisation", "identifier"], + message: "Required", + }); + } + break; + default: + break; + } + if (val.applicantType !== ApplicantTypeChoice.Individual) { + if (val.organisation?.name == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["organisation", "name"], + message: "Required", + }); + } + if (val.organisation?.coreBusiness == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["organisation", "coreBusiness"], + message: "Required", + }); + } + if (!val.organisation?.address?.streetAddress) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["organisation", "address", "streetAddress"], + message: "Required", + }); + } + if (!val.organisation?.address?.city) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["organisation", "address", "city"], + message: "Required", + }); + } + if (!val.organisation?.address?.postCode) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["organisation", "address", "postCode"], + message: "Required", + }); + } + } + // TODO need to split + if (!val.contactPerson?.firstName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["contactPerson", "firstName"], + message: "Required", + }); + } + if (!val.contactPerson?.lastName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["contactPerson", "lastName"], + message: "Required", + }); + } + if (!val.contactPerson?.email) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["contactPerson", "email"], + message: "Required", + }); + } + if (!val.contactPerson?.phoneNumber) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["contactPerson", "phoneNumber"], + message: "Required", + }); + } + + // TODO need to check the subfields of the address + if (val.hasBillingAddress) { + if (!val.billingAddress?.streetAddress) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["billingAddress", "streetAddress"], + message: "Required", + }); + } + if (!val.billingAddress?.city) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["billingAddress", "city"], + message: "Required", + }); + } + if (!val.billingAddress?.postCode) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["billingAddress", "postCode"], + message: "Required", + }); + } + } + }); + export type ApplicationFormPage3Values = z.infer< typeof ApplicationFormPage3Schema >; diff --git a/apps/ui/components/application/OrganisationForm.tsx b/apps/ui/components/application/OrganisationForm.tsx index ae952eaf7..d2bf0feee 100644 --- a/apps/ui/components/application/OrganisationForm.tsx +++ b/apps/ui/components/application/OrganisationForm.tsx @@ -6,7 +6,6 @@ import styled from "styled-components"; import { breakpoints } from "common/src/common/style"; import { CheckboxWrapper } from "common/src/reservation-form/components"; import { ApplicantTypeChoice } from "@gql/gql-types"; -import { applicationErrorText } from "@/modules/util"; import { EmailInput } from "./EmailInput"; import { BillingAddress } from "./BillingAddress"; import type { ApplicationFormPage3Values } from "./Form"; @@ -28,9 +27,9 @@ type Props = { homeCityOptions: OptionType[]; }; -export const OrganisationForm = ({ +export function OrganisationForm({ homeCityOptions, -}: Props): JSX.Element | null => { +}: Props): JSX.Element | null { const { t } = useTranslation(); const { @@ -54,37 +53,41 @@ export const OrganisationForm = ({ } }, [hasRegistration, register, unregister]); + useEffect(() => { + if (hasBillingAddress) { + register("billingAddress", { required: true }); + register("billingAddress.postCode", { required: true }); + register("billingAddress.city", { required: true }); + } else { + unregister("billingAddress"); + unregister("billingAddress.postCode"); + unregister("billingAddress.city"); + } + }, [hasBillingAddress, register, unregister]); + + const translateError = (errorMsg?: string) => + errorMsg ? t(`application:validation.${errorMsg}`) : ""; + return ( {t("application:Page3.subHeading.basicInfo")} - {t("application:Page3.subHeading.postalAddress")} ); -}; +} diff --git a/apps/ui/components/application/TimeSelector.tsx b/apps/ui/components/application/TimeSelector.tsx index 988a5f5b2..adacdb18a 100644 --- a/apps/ui/components/application/TimeSelector.tsx +++ b/apps/ui/components/application/TimeSelector.tsx @@ -435,7 +435,7 @@ export function TimeSelector({ labelHead={t(`common:weekDay.${fromMondayFirstUnsafe(day)}`)} cells={cells[day]} setCellValue={setCellValue} - priority={priority} + priority={priority ?? 200} /> ))} diff --git a/apps/ui/pages/applications/[id]/page3.tsx b/apps/ui/pages/applications/[id]/page3.tsx index 032b68442..a9a0ee466 100644 --- a/apps/ui/pages/applications/[id]/page3.tsx +++ b/apps/ui/pages/applications/[id]/page3.tsx @@ -25,6 +25,7 @@ import { type PersonFormValues, type AddressFormValues, type OrganisationFormValues, + ApplicationFormPage3Schema, } from "@/components/application/Form"; import { ApplicationPageWrapper } from "@/components/application/ApplicationPage"; import { useApplicationUpdate } from "@/hooks/useApplicationUpdate"; @@ -36,6 +37,7 @@ import { getApplicationPath } from "@/modules/urls"; import { Button, ButtonVariant, IconArrowRight } from "hds-react"; import { ButtonContainer } from "common/styles/util"; import styled from "styled-components"; +import { zodResolver } from "@hookform/resolvers/zod"; function Buttons({ applicationPk, @@ -76,7 +78,6 @@ function transformPerson(person?: PersonFormValues) { }; } -type Node = NonNullable; function isAddressValid(address?: AddressFormValues) { const { streetAddress, postCode, city } = address || {}; return ( @@ -111,8 +112,10 @@ function transformOrganisation(org: OrganisationFormValues) { }; } +type ApplicationT = NonNullable; + function convertApplicationToForm( - app?: Maybe + app?: Maybe ): ApplicationFormPage3Values { return { pk: app?.pk ?? 0, @@ -161,7 +164,6 @@ function Page3(): JSX.Element | null { const { cityOptions } = options; const { watch } = useFormContext(); - const type = watch("applicantType"); switch (type) { @@ -203,7 +205,8 @@ function Page3Wrapped(props: PropsNarrowed): JSX.Element | null { defaultValues: convertApplicationToForm(application), // No resolver because different types require different mandatory values. // Would need to write more complex validation logic that branches based on the type. - // resolver: zodResolver(ApplicationFormPage3Schema), + resolver: zodResolver(ApplicationFormPage3Schema), + reValidateMode: "onChange", }); const { diff --git a/packages/common/src/components/form/ControlledCheckbox.tsx b/packages/common/src/components/form/ControlledCheckbox.tsx new file mode 100644 index 000000000..0caedc738 --- /dev/null +++ b/packages/common/src/components/form/ControlledCheckbox.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { Checkbox } from "hds-react"; +import styled from "styled-components"; +import { fontRegular } from "../../common/typography"; +import { Control, FieldValues, useController } from "react-hook-form"; + +const StyledCheckbox = styled(Checkbox)` + && label { + ${fontRegular}; + line-height: var(--lineheight-l); + + a { + text-decoration: underline; + color: var(--color-black); + } + } +`; + +type Props = { + id?: string; + name: string; + label: string; + control: Control; + required?: boolean; + defaultValue?: boolean; + error?: string; +}; + +export function ControlledCheckbox({ + id, + control, + name, + required, + defaultValue, + ...props +}: Props) { + const { + field: { value, onChange }, + } = useController({ control, name, defaultValue, rules: { required } }); + + return ( + onChange(e.target.checked)} + checked={value} + defaultChecked={defaultValue} + label={props.label} + errorText={props.error} + /> + ); +} diff --git a/packages/common/src/reservation-form/ReservationFormField.tsx b/packages/common/src/reservation-form/ReservationFormField.tsx index ef76ca89d..afdfebe7c 100644 --- a/packages/common/src/reservation-form/ReservationFormField.tsx +++ b/packages/common/src/reservation-form/ReservationFormField.tsx @@ -1,20 +1,16 @@ -import { Checkbox, NumberInput, TextArea, TextInput } from "hds-react"; +import { NumberInput, TextArea, TextInput } from "hds-react"; import get from "lodash/get"; import React, { useMemo } from "react"; -import { - Control, - Controller, - FieldValues, - useFormContext, -} from "react-hook-form"; +import { useFormContext } from "react-hook-form"; import { useTranslation } from "next-i18next"; import styled from "styled-components"; -import { fontMedium, fontRegular, Strongish } from "../common/typography"; +import { fontMedium, Strongish } from "../common/typography"; import { CustomerTypeChoice } from "../../gql/gql-types"; import { Inputs, Reservation } from "./types"; import { CheckboxWrapper } from "./components"; import { OptionType } from "../../types/common"; import { ControlledSelect } from "../components/form"; +import { ControlledCheckbox } from "../components/form/ControlledCheckbox"; type Props = { field: keyof Inputs; @@ -81,45 +77,6 @@ const StyledTextArea = styled(TextArea)` } `; -const StyledCheckbox = styled(Checkbox)` - && label { - ${fontRegular}; - line-height: var(--lineheight-l); - - a { - text-decoration: underline; - color: var(--color-black); - } - } -`; - -const ControlledCheckbox = (props: { - field: string; - control: Control; - required: boolean; - label: string; - defaultValue?: boolean; - errorText?: string; - defaultChecked?: boolean; -}) => ( - ( - onChange(e.target.checked)} - checked={value} - defaultChecked={props.defaultChecked} - label={props.label} - errorText={props.errorText} - /> - )} - /> -); - /* NOTE: backend returns validation errors if text fields are too long * remove maxlength after adding proper schema validation */ @@ -341,7 +298,7 @@ const ReservationFormField = ({ const checkParams = { id, - field, + name: field, control, defaultValue: typeof defaultValue === "boolean" ? defaultValue : undefined, label, @@ -371,7 +328,7 @@ const ReservationFormField = ({ );