diff --git a/app/components/formElements/SchemaComponents.tsx b/app/components/formElements/SchemaComponents.tsx index 6925ff5d61..998c9c3e15 100644 --- a/app/components/formElements/SchemaComponents.tsx +++ b/app/components/formElements/SchemaComponents.tsx @@ -1,4 +1,3 @@ -import { type SchemaObject } from "~/domains/userData"; import { type StrapiFormComponent } from "~/services/cms/models/formElements/StrapiFormComponent"; import { getNestedSchema } from "../formElements/schemaToForm/getNestedSchema"; import { @@ -28,22 +27,27 @@ import { isZodNumber, renderZodNumber, } from "~/components/formElements/schemaToForm/renderZodNumber"; +import { + hasControlledFieldConfig, + type ArrayPage, + type PageConfig, +} from "~/domains/pageSchemas"; type Props = { - pageSchema: SchemaObject; + pageConfig: ArrayPage | PageConfig; formComponents?: StrapiFormComponent[]; className?: string; readOnlyFieldNames: string[]; }; export const SchemaComponents = ({ - pageSchema, + pageConfig: { pageSchema, controlledFieldConfig }, formComponents, className, readOnlyFieldNames, }: Props) => { const sortedFieldsSchema = sortSchemaByFormComponents( - pageSchema, + pageSchema ?? {}, formComponents, ); @@ -56,6 +60,10 @@ export const SchemaComponents = ({ fieldName, formComponents ?? [], ); + const hasControlledField = hasControlledFieldConfig( + fieldName, + controlledFieldConfig, + ); if (fieldSetGroup !== undefined) { return renderFieldSet(fieldName, fieldSetGroup, readOnlyFieldNames); @@ -73,6 +81,7 @@ export const SchemaComponents = ({ return renderSpecialMetaDescriptions( fieldName, description, + controlledFieldConfig, matchingElement, ); } @@ -96,7 +105,12 @@ export const SchemaComponents = ({ return renderZodNumber(fieldName, nestedSchema, matchingElement); if (isZodString(nestedSchema)) - return renderZodString(fieldName, isFieldReadOnly, matchingElement); + return renderZodString( + fieldName, + isFieldReadOnly, + matchingElement, + hasControlledField, + ); })} ); diff --git a/app/components/formElements/ValidatedFormFlow.tsx b/app/components/formElements/ValidatedFormFlow.tsx index e233b7b8cc..27ca6dc130 100644 --- a/app/components/formElements/ValidatedFormFlow.tsx +++ b/app/components/formElements/ValidatedFormFlow.tsx @@ -1,6 +1,6 @@ import { ValidatedForm } from "@rvf/react-router"; import { useLocation } from "react-router"; -import { getPageSchema } from "~/domains/pageSchemas"; +import { getPageConfigOrArrayPageByPathname } from "~/domains/pageSchemas"; import type { UserData } from "~/domains/userData"; import type { StrapiFormComponent } from "~/services/cms/models/formElements/StrapiFormComponent"; import { buildStepSchemaWithPageSchema } from "~/services/validation/stepValidator/buildStepSchemaWithPageSchema"; @@ -24,8 +24,11 @@ function ValidatedFlowForm({ buttonNavigationProps: { back, next }, }: Readonly) { const { pathname } = useLocation(); - const pageSchema = getPageSchema(pathname); - const formSchema = buildStepSchemaWithPageSchema(pathname, pageSchema); + const pageConfig = getPageConfigOrArrayPageByPathname(pathname); + const formSchema = buildStepSchemaWithPageSchema( + pathname, + pageConfig?.pageSchema, + ); const readOnlyFieldNames = getReadOnlyFieldNames(pathname, stepData); return ( @@ -41,9 +44,9 @@ function ValidatedFlowForm({ <>
- {pageSchema && ( + {pageConfig && ( { props: Readonly[0]>, ) { const form = useForm({ - schema: z.object(props.pageSchema), + schema: z.object(props.pageConfig?.pageSchema), defaultValues: {}, }); @@ -47,7 +47,7 @@ describe("SchemaComponents", () => { const pageSchema = { field1: z.string() }; const { getByRole } = render( , ); @@ -58,7 +58,7 @@ describe("SchemaComponents", () => { it("should render textarea", () => { const { getByRole } = render( { const pageSchema = { field1: z.enum(["option1", "option2"]) }; const { getAllByRole } = render( , ); @@ -96,7 +96,7 @@ describe("SchemaComponents", () => { }; const { getAllByRole } = render( , ); @@ -111,7 +111,7 @@ describe("SchemaComponents", () => { const pageSchema = { [fieldName]: z.enum(["option1"]) }; const { getByRole } = render( { const pageSchema = { [fieldName]: checkedRequired }; const { getByRole } = render( { const pageSchema = { field: exclusiveCheckboxesSchema(["option", "none"]) }; const { getAllByRole } = render( { const { getByRole, getAllByRole } = render( val.toUpperCase()), - field2: z.enum(["option1", "option2"]), + pageConfig={{ + pageSchema: { + field1: z + .string() + .min(1) + .transform((val) => val.toUpperCase()), + field2: z.enum(["option1", "option2"]), + }, }} />, ); @@ -226,7 +228,7 @@ describe("SchemaComponents", () => { it("should attach correct labels to inputs", () => { const { getByRole, getByLabelText, getByText } = render( { const { getByRole } = render( , @@ -277,7 +279,7 @@ describe("SchemaComponents", () => { const pageSchema = { [fieldName]: createNumberIncrementSchema(-2, 18) }; const { getByRole } = render( { const { getAllByRole, getByText } = render( , ); @@ -351,7 +353,7 @@ describe("SchemaComponents", () => { const pageSchema = { field1: z.string(), field2: z.string() }; const { getAllByRole } = render( , ); @@ -371,17 +373,27 @@ describe("SchemaComponents", () => { const pageSchema = { field1: ibanSchema, }; - const { getByRole } = render( + const { getByLabelText } = render( , ); - const ibanInput = getByRole("textbox"); + const ibanInput = getByLabelText("label"); expect(ibanInput).toHaveAttribute("name", "field1"); - expect(ibanInput.getAttribute("aria-describedby")).toContain( - "bank-name-badge", - ); }); it("should render a telephone input when the schema is phoneNumberSchema", () => { @@ -390,7 +402,7 @@ describe("SchemaComponents", () => { }; const { getByRole } = render( , ); diff --git a/app/components/formElements/__test__/ValidatedFlowForm.test.tsx b/app/components/formElements/__test__/ValidatedFlowForm.test.tsx index 9b36685a15..64711b2688 100644 --- a/app/components/formElements/__test__/ValidatedFlowForm.test.tsx +++ b/app/components/formElements/__test__/ValidatedFlowForm.test.tsx @@ -12,7 +12,7 @@ import { checkedRequired } from "~/services/validation/checkedCheckbox"; import { configureZod } from "~/services/validation/configureZod"; import { createDateSchema } from "~/services/validation/dateString"; import { integerSchema } from "~/services/validation/integer"; -import { getPageSchema } from "~/domains/pageSchemas"; +import { getPageConfigOrArrayPageByPathname } from "~/domains/pageSchemas"; import { stringRequiredSchema } from "~/services/validation/stringRequired"; import { timeSchema } from "~/services/validation/time"; import { YesNoAnswer } from "~/services/validation/YesNoAnswer"; @@ -38,8 +38,10 @@ vi.mock("~/services/params", () => ({ vi.mock("~/domains/pageSchemas"); -const mockGetPageSchema = (pageSchema: SchemaObject | undefined) => { - vi.mocked(getPageSchema).mockReturnValue(pageSchema); +const mockGetPageConfigOrArrayPageByPathname = ( + pageSchema: SchemaObject | undefined, +) => { + vi.mocked(getPageConfigOrArrayPageByPathname).mockReturnValue({ pageSchema }); }; vi.spyOn(parsePathname, "parsePathname").mockResolvedValue({ @@ -54,14 +56,14 @@ describe("ValidatedFlowForm", () => { }); it("should render", () => { - mockGetPageSchema(undefined); + mockGetPageConfigOrArrayPageByPathname(undefined); const { getByText } = renderValidatedFlowForm([]); expect(getByText("NEXT")).toBeInTheDocument(); }); describe("Input Component", () => { beforeAll(() => { - mockGetPageSchema({ inputName: integerSchema }); + mockGetPageConfigOrArrayPageByPathname({ inputName: integerSchema }); }); const { component, expectInputErrorToExist } = getStrapiInputComponent({ code: "invalidInteger", @@ -108,7 +110,7 @@ describe("ValidatedFlowForm", () => { describe("Date Input Component", () => { beforeAll(() => { - mockGetPageSchema({ inputName: createDateSchema() }); + mockGetPageConfigOrArrayPageByPathname({ inputName: createDateSchema() }); }); const { component, expectInputErrorToExist } = getStrapiInputComponent( { @@ -158,7 +160,7 @@ describe("ValidatedFlowForm", () => { describe("Time Input Component", () => { beforeAll(() => { - mockGetPageSchema({ inputName: timeSchema }); + mockGetPageConfigOrArrayPageByPathname({ inputName: timeSchema }); }); const { component, expectInputErrorToExist } = getStrapiInputComponent( { @@ -208,7 +210,9 @@ describe("ValidatedFlowForm", () => { describe("Textarea Component", () => { beforeAll(() => { - mockGetPageSchema({ myTextarea: stringRequiredSchema }); + mockGetPageConfigOrArrayPageByPathname({ + myTextarea: stringRequiredSchema, + }); }); const { component, expectTextareaErrorToExist } = getStrapiTextareaComponent({ @@ -253,7 +257,7 @@ describe("ValidatedFlowForm", () => { describe("Select Component (Radio)", () => { beforeAll(() => { - mockGetPageSchema({ mySelect: YesNoAnswer }); + mockGetPageConfigOrArrayPageByPathname({ mySelect: YesNoAnswer }); }); const { component, expectSelectErrorToExist } = getStrapiSelectComponent({ code: "required", @@ -298,7 +302,7 @@ describe("ValidatedFlowForm", () => { describe("Dropdown Component", () => { beforeAll(() => { - mockGetPageSchema({ + mockGetPageConfigOrArrayPageByPathname({ myDropdown: z.enum(["option1", "option2", "option3"]), }); }); @@ -338,7 +342,7 @@ describe("ValidatedFlowForm", () => { describe("Checkbox Component", () => { beforeAll(() => { - mockGetPageSchema({ + mockGetPageConfigOrArrayPageByPathname({ checkbox1: checkedRequired, checkbox2: checkedRequired, }); @@ -402,7 +406,7 @@ describe("ValidatedFlowForm", () => { describe("TileGroup Component", () => { beforeAll(() => { - mockGetPageSchema({ + mockGetPageConfigOrArrayPageByPathname({ myTileGroup: z.enum(["firstTile", "secondTile"]), }); }); diff --git a/app/components/formElements/inputs/fieldset/Fieldset.tsx b/app/components/formElements/inputs/fieldset/Fieldset.tsx index b8e8312f56..a9fb65d3f2 100644 --- a/app/components/formElements/inputs/fieldset/Fieldset.tsx +++ b/app/components/formElements/inputs/fieldset/Fieldset.tsx @@ -84,7 +84,7 @@ export const Fieldset = ({ )}
{ + return bankName ? ( + + + {bankName} + + ) : null; +}; diff --git a/app/components/formElements/inputs/iban/IbanInput.tsx b/app/components/formElements/inputs/iban/IbanInput.tsx index 2054d3152d..5c85efb28d 100644 --- a/app/components/formElements/inputs/iban/IbanInput.tsx +++ b/app/components/formElements/inputs/iban/IbanInput.tsx @@ -1,68 +1,38 @@ -import { useField } from "@rvf/react-router"; -import { useEffect, useState, type FunctionComponent } from "react"; +import { type FunctionComponent } from "react"; import { IMaskMixin, type IMaskMixinProps } from "react-imask"; -import { Icon } from "~/components/common/Icon"; import TextInput, { type InputProps } from "../text/TextInput"; -import { useBankData } from "./useBankData"; -import { bankNameFromIBAN } from "./bankNameFromIBAN"; +import { useControlledField } from "~/components/hooks/useControlledField"; +import { type ControlledFieldConfig } from "~/domains/pageSchemas"; type MaskedIbanInputProps = InputProps & IMaskMixinProps; const MaskedIbanInput: FunctionComponent = IMaskMixin< HTMLInputElement, InputProps ->((props) => ); - -const bankNameBadgeId = "bank-name-badge"; - -const IbanInput = (props: InputProps) => { - const field = useField(props.name); - const iban = field.value(); - const [bankName, setBankName] = useState(); - const banks = useBankData(); - - // Debounce needed to not clobber the screen reader while typing - useEffect(() => { - if (iban && banks) { - const timeout = setTimeout(() => { - const matchedBankName = bankNameFromIBAN(iban, banks); - setBankName(matchedBankName); - }, 1000); - - return () => clearTimeout(timeout); - } - setBankName(undefined); - }, [iban, banks]); +>((props) => { + return ; +}); + +const IbanInput = ( + props: InputProps & { + controlledFieldConfig?: ControlledFieldConfig; + }, +) => { + const { SrAnnouncementComponent } = useControlledField( + props.name, + props.controlledFieldConfig, + ); return (
str.toUpperCase()} - ariaDescribedBy={bankNameBadgeId} + ariaDescribedBy="bankName" {...props} /> - {/* Badge display of bank name for sighted users */} - {bankName && ( - - - {bankName} - - )} - {/* Screenreader-only element used to read out bank name when it changes */} -
- {bankName} -
+ {SrAnnouncementComponent}
); }; diff --git a/app/components/formElements/inputs/iban/__test__/IbanInput.test.tsx b/app/components/formElements/inputs/iban/__test__/IbanInput.test.tsx index b81e743d1c..c61e5b35a9 100644 --- a/app/components/formElements/inputs/iban/__test__/IbanInput.test.tsx +++ b/app/components/formElements/inputs/iban/__test__/IbanInput.test.tsx @@ -1,60 +1,82 @@ -import { useField } from "@rvf/react-router"; -import { fireEvent, render, waitFor } from "@testing-library/react"; -import { formatIban } from "~/services/validation/iban"; -import { type BankData } from "../bankNameFromIBAN"; +import { formatIban, ibanSchema } from "~/services/validation/iban"; import IbanInput from "../IbanInput"; +import { userEvent } from "@testing-library/user-event"; +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { z } from "zod"; +import { type JSX } from "react"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { FormProvider, useForm } from "@rvf/react"; -const mockValue = vi.fn(); const mockIBAN = "DE02120300000000202051"; -const mockBankName = "Deutsche Kreditbank Suhl"; - -vi.mock("@rvf/react-router"); -vi.mocked(useField).mockImplementation( - () => - ({ - value: mockValue, - error: vi.fn(), - getInputProps: vi.fn( - () => - ({ - id: "iban", - }) as any, - ), - }) as any, -); - -vi.mock("~/components/formElements/inputs/iban/useBankData.ts", () => ({ - useBankData: vi.fn( - () => - ({ - [Number(mockIBAN.substring(4, 12))]: mockBankName, - }) as BankData, - ), + +vi.mock("~/components/hooks/useControlledField.tsx", () => ({ + useControlledField: () => ({ + SrAnnouncementComponent: null, + }), })); -describe("KernIbanInput", () => { +const user = userEvent.setup(); + +const RVFWrapper = ({ + children, + defaultValues = { iban: "" }, +}: { + children: JSX.Element; + defaultValues?: { iban: string }; +}) => { + const form = useForm({ + schema: z.object({ iban: ibanSchema }), + defaultValues, + }); + + const router = createMemoryRouter([ + { + path: "/", + element: ( + +
{children}
+
+ ), + }, + ]); + + return ; +}; + +describe("IbanInput", () => { it("should render a user-entered IBAN with masked spaces between digit groups", async () => { - const { getByLabelText } = render(); + const { getByLabelText } = render( + + + , + ); const input = getByLabelText("IBAN"); expect(input).toHaveValue(""); - fireEvent.input(input, { target: { value: mockIBAN } }); + user.type(input, mockIBAN); await waitFor(() => { expect(input).toHaveValue(formatIban(mockIBAN)); }); }); - it("should display the matching bank name if found", async () => { - mockValue.mockReturnValue(mockIBAN); - const { getAllByText } = render(); - - await waitFor( - () => { - const bankNames = getAllByText(mockBankName); - expect(bankNames).toHaveLength(2); - expect(bankNames[0]).toHaveClass("kern-label kern-label--small"); - }, - { timeout: 1100 }, + it("should display an error if an invalid IBAN is entered", async () => { + const { getByLabelText } = render( + + + , ); + const input = getByLabelText("IBAN"); + + user.type(input, "invalid iban"); + fireEvent.blur(input); + + await waitFor(() => { + expect(input.parentElement).toHaveClass("kern-form-input--error"); + expect(input).toHaveAttribute("aria-invalid", "true"); + }); }); }); diff --git a/app/components/formElements/inputs/iban/useBankData.ts b/app/components/formElements/inputs/iban/useBankData.ts deleted file mode 100644 index 8f2f4637eb..0000000000 --- a/app/components/formElements/inputs/iban/useBankData.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect, useState } from "react"; -import { type BankData } from "./bankNameFromIBAN"; - -export function useBankData() { - const [bankData, setBankData] = useState(); - - useEffect(() => { - const fetchData = async () => { - try { - const response = await fetch("/api/banks/list"); - - if (response.ok) { - const json = await response.json(); - setBankData(json); - } - } catch { - setBankData(undefined); - } - }; - fetchData(); - }, []); - - return bankData; -} diff --git a/app/components/formElements/inputs/number/NumberInput.tsx b/app/components/formElements/inputs/number/NumberInput.tsx index 9662183ac2..bec55ba1ec 100644 --- a/app/components/formElements/inputs/number/NumberInput.tsx +++ b/app/components/formElements/inputs/number/NumberInput.tsx @@ -8,9 +8,6 @@ import { InputHelperText } from "../helperText/InputHelperText"; type InputProps = Readonly<{ name: string; label?: string; - type?: string; - step?: string | number; - prefix?: string; suffix?: string; readonly?: boolean; charLimit?: number; @@ -20,7 +17,6 @@ type InputProps = Readonly<{ }>; const NumberInput = function InputComponent({ - step, name, label, suffix, @@ -50,12 +46,12 @@ const NumberInput = function InputComponent({ "kern-form-input__input--error": field.error(), })} {...field.getInputProps({ - step, id: name, inputMode: "decimal", placeholder, })} name={name} + type="text" aria-invalid={field.error() !== null} aria-describedby={[ field.error() && errorId, diff --git a/app/components/formElements/inputs/number/__test__/NumberInput.test.tsx b/app/components/formElements/inputs/number/__test__/NumberInput.test.tsx index 1d9755f936..db24ff10f7 100644 --- a/app/components/formElements/inputs/number/__test__/NumberInput.test.tsx +++ b/app/components/formElements/inputs/number/__test__/NumberInput.test.tsx @@ -88,11 +88,6 @@ describe("NumberInput", () => { expect(getByRole("textbox")).toHaveAttribute("readonly"); }); - it("should apply step attribute when provided", () => { - const { getByRole } = render(); - expect(getByRole("textbox")).toHaveAttribute("step", "0.01"); - }); - it("should apply placeholder when provided", () => { const { getByPlaceholderText } = render( , diff --git a/app/components/formElements/inputs/text/TextInput.tsx b/app/components/formElements/inputs/text/TextInput.tsx index e5b9ac905c..18fc1bb56b 100644 --- a/app/components/formElements/inputs/text/TextInput.tsx +++ b/app/components/formElements/inputs/text/TextInput.tsx @@ -1,18 +1,15 @@ -import { useField } from "@rvf/react-router"; +import { type FieldApi, useField } from "@rvf/react-router"; import classNames from "classnames"; import { INPUT_CHAR_LIMIT } from "~/services/validation/inputlimits"; import { type ErrorMessageProps } from "~/components/common/types"; -import { type InputHTMLAttributes } from "react"; import InputError from "../error/InputError"; +import { type InputHTMLAttributes } from "react"; import { InputLabel } from "../label/InputLabel"; import { InputHelperText } from "../helperText/InputHelperText"; export type InputProps = Readonly<{ name: string; label?: string; - type?: string; - step?: string | number; - prefix?: string; suffix?: string; inputRef?: React.Ref; readonly?: boolean; @@ -21,8 +18,30 @@ export type InputProps = Readonly<{ placeholder?: string; errorMessages?: ErrorMessageProps[]; ariaDescribedBy?: InputHTMLAttributes["aria-describedby"]; + /** + * Any TextInput could theoretically be controlled by another input on the same page, + * for example, the IbanInput and bank name. + */ + controlled?: boolean; }>; +const getInputValue = (field: FieldApi, controlled: boolean) => { + if (field.getInputProps().value !== undefined) { + return field.getInputProps().value; + } + + /** For controlled fields we want to use field.value() because can be modified by another input, e.g. the bank name is modified by the IbanInput. + * In case the value is undefined, we return an empty string to avoid uncontrolled input warnings. + * */ + + if (controlled) { + return (field.value() as string | number | undefined) ?? ""; + } + + // For uncontrolled fields we want to always return undefined + return undefined; +}; + const TextInput = function InputComponent({ name, label, @@ -33,11 +52,13 @@ const TextInput = function InputComponent({ placeholder, errorMessages, ariaDescribedBy, + controlled = false, charLimit = INPUT_CHAR_LIMIT, }: InputProps) { const field = useField(name); const errorId = `${name}-error`; const helperId = `${name}-helper`; + return (
({ - useField: () => ({ - error: () => "required", - getInputProps: () => ({ - id: "text", - name: "text", - inputMode: "text", - value: "", - placeholder: "Enter text", - onChange: vi.fn(), - }), - }), -})); +vi.mock("@rvf/react-router"); +vi.mocked(useField).mockImplementation( + () => + ({ + value: vi.fn(), + error: () => "required", + getInputProps: () => ({ + id: "text", + name: "text", + inputMode: "text", + value: "", + defaultValue: "default", + placeholder: "Enter text", + onChange: vi.fn(), + }), + }) as any, +); describe("TextInput", () => { it("should render input with correct attributes", () => { @@ -85,4 +90,100 @@ describe("TextInput", () => { const { getByRole } = render(); expect(ref.current).toBe(getByRole("textbox")); }); + + it("should set value when getInputProps.value() is provided", () => { + vi.mocked(useField).mockReturnValue({ + error: () => "required", + getInputProps: () => ({ + id: "text", + name: "text", + inputMode: "text", + value: "value existing", + defaultValue: "default", + placeholder: "Enter text", + onChange: vi.fn(), + }), + } as any); + const { getByRole } = render(); + + expect(getByRole("textbox")).toHaveAttribute("value", "value existing"); + }); + + it("should set value when as undefined when input is not controlled and getInputProps.value() is not provided ", () => { + vi.mocked(useField).mockReturnValue({ + error: () => "required", + getInputProps: () => ({ + id: "text", + name: "text", + inputMode: "text", + value: undefined, + defaultValue: "default", + placeholder: "Enter text", + onChange: vi.fn(), + }), + } as any); + const { getByRole } = render(); + + expect(getByRole("textbox")).toHaveAttribute("value", undefined); + }); + + it("should set value from value() function when input is controlled and getInputProps.value() is not provided", () => { + vi.mocked(useField).mockReturnValue({ + error: () => "required", + getInputProps: () => ({ + id: "text", + name: "text", + inputMode: "text", + value: undefined, + defaultValue: "default", + placeholder: "Enter text", + onChange: vi.fn(), + }), + value: () => "some value from function", + } as any); + const { getByRole } = render(); + + expect(getByRole("textbox")).toHaveAttribute( + "value", + "some value from function", + ); + }); + + it("should set the default value when not controlled", () => { + vi.mocked(useField).mockReturnValue({ + error: () => "required", + getInputProps: () => ({ + id: "text", + name: "text", + inputMode: "text", + value: undefined, + defaultValue: "default", + placeholder: "Enter text", + onChange: vi.fn(), + }), + } as any); + const { getByRole } = render(); + + expect((getByRole("textbox") as HTMLInputElement).defaultValue).toBe( + "default", + ); + }); + + it("should not set defaultValue when controlled", () => { + vi.mocked(useField).mockReturnValue({ + error: () => "required", + getInputProps: () => ({ + id: "text", + name: "text", + inputMode: "text", + value: undefined, + defaultValue: "default", + placeholder: "Enter text", + onChange: vi.fn(), + }), + value: () => "", + } as any); + const { getByRole } = render(); + expect((getByRole("textbox") as HTMLInputElement).defaultValue).toBe(""); + }); }); diff --git a/app/components/formElements/schemaToForm/renderSchemaBasedFormElement.tsx b/app/components/formElements/schemaToForm/renderSchemaBasedFormElement.tsx index ceb4b3a8af..edf3564513 100644 --- a/app/components/formElements/schemaToForm/renderSchemaBasedFormElement.tsx +++ b/app/components/formElements/schemaToForm/renderSchemaBasedFormElement.tsx @@ -10,6 +10,7 @@ import { mapLookValue } from "~/components/content/ContentComponents"; import HiddenInput from "../inputs/hidden/HiddenInput"; import IbanInput from "../inputs/iban/IbanInput"; import TelephoneInput from "../inputs/telephone/TelephoneInput"; +import { type ControlledFieldConfig } from "~/domains/pageSchemas"; const specialComponentDescriptions = [ filesUploadZodDescription, @@ -44,6 +45,7 @@ export const isSpecialComponentDescriptions = ( export const renderSpecialMetaDescriptions = ( fieldName: string, description: SpecialComponentDescription, + controlledFieldConfig?: ControlledFieldConfig, matchingElement?: StrapiFormComponent, ) => { if (description === filesUploadZodDescription) { @@ -72,7 +74,14 @@ export const renderSpecialMetaDescriptions = ( } if (description === ibanZodDescription) { - return ; + return ( + + ); } if (description === phoneNumberZodDescription) { diff --git a/app/components/formElements/schemaToForm/renderZodObject.tsx b/app/components/formElements/schemaToForm/renderZodObject.tsx index e2ad189363..832cebb53f 100644 --- a/app/components/formElements/schemaToForm/renderZodObject.tsx +++ b/app/components/formElements/schemaToForm/renderZodObject.tsx @@ -39,7 +39,7 @@ export const renderZodObject = ( return ( diff --git a/app/components/formElements/schemaToForm/renderZodString.tsx b/app/components/formElements/schemaToForm/renderZodString.tsx index e68b536f7f..5f2420e083 100644 --- a/app/components/formElements/schemaToForm/renderZodString.tsx +++ b/app/components/formElements/schemaToForm/renderZodString.tsx @@ -16,17 +16,19 @@ export const renderZodString = ( fieldName: string, isFieldReadOnly: boolean, matchingElement?: StrapiFormComponent, + isControlled: boolean = false, ) => { const sharedProps = { name: fieldName, readonly: isFieldReadOnly, label: fieldName, // fallback, will get written if there's a matchingElement ...pick(matchingElement, ["label", "placeholder", "errorMessages"]), + controlled: isControlled, }; const inputProps = { ...sharedProps, - ...pick(matchingElement, ["type", "suffix", "width", "helperText"]), + ...pick(matchingElement, ["suffix", "width", "helperText"]), } satisfies InputProps; if (matchingElement?.__component === "form-elements.textarea") @@ -47,15 +49,13 @@ export const renderZodString = ( ); const inputType = - ((inputProps as InputProps).type as "text" | "number" | undefined) ?? - "text"; + matchingElement && "type" in matchingElement + ? matchingElement.type + : "text"; - switch (inputType) { - case "text": - return ; - case "number": - return ; - default: - return ; - } + return inputType === "text" ? ( + + ) : ( + + ); }; diff --git a/app/components/hooks/__test__/useControlledField.test.tsx b/app/components/hooks/__test__/useControlledField.test.tsx new file mode 100644 index 0000000000..a4e7cf4905 --- /dev/null +++ b/app/components/hooks/__test__/useControlledField.test.tsx @@ -0,0 +1,42 @@ +import { render, renderHook, waitFor } from "@testing-library/react"; +import { useControlledField } from "~/components/hooks/useControlledField"; +import { kontopfaendungPkontoAntragPages } from "~/domains/kontopfaendung/pkonto/antrag/pages"; + +vi.mock("@rvf/react-router", () => ({ + useField: () => ({ + value: vi.fn(), + }), + useFormContext: () => ({ + field: vi.fn(), + }), +})); + +describe("useControlledField", () => { + it("should return a screenreader announcement component", () => { + const { result } = renderHook(() => + useControlledField( + "iban", + kontopfaendungPkontoAntragPages.bankdatenKontodaten + .controlledFieldConfig, + ), + ); + const { container } = render(result.current.SrAnnouncementComponent); + const srComponent = container.querySelector("div"); + expect(srComponent).toBeDefined(); + expect(srComponent).toHaveAttribute("aria-live", "polite"); + }); + + it("should call the field value change handler", async () => { + const mockValueChangeHandler = vi.fn(); + renderHook(() => + useControlledField("iban", { + ...kontopfaendungPkontoAntragPages.bankdatenKontodaten + .controlledFieldConfig, + handleFieldValueChange: mockValueChangeHandler, + }), + ); + await waitFor(() => { + expect(mockValueChangeHandler).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/hooks/useControlledField.tsx b/app/components/hooks/useControlledField.tsx new file mode 100644 index 0000000000..764d1acceb --- /dev/null +++ b/app/components/hooks/useControlledField.tsx @@ -0,0 +1,68 @@ +import { useField, useFormContext } from "@rvf/react-router"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { type ControlledFieldConfig } from "~/domains/pageSchemas"; +import { type AllowedUserTypes } from "~/domains/userData"; + +export const useControlledField = ( + fieldName: string, + controlledFieldConfig?: ControlledFieldConfig, +) => { + const { + fieldName: controlledFieldName, + getScreenReaderAnnouncementText, + handleFieldValueChange, + } = controlledFieldConfig ?? {}; + const field = useField(fieldName); + const value = field.value(); + const originalValue = useRef(value).current; + const form = useFormContext(); + const controlledField = form?.field(controlledFieldName ?? ""); + const [controlledFieldSrValue, setControlledFieldSrValue] = + useState(); + + useEffect(() => { + async function fieldValueChangeHandler() { + await handleFieldValueChange?.({ + originalValue, + value, + controlledField, + setControlledFieldSrValue, + }); + } + fieldValueChangeHandler(); + }, [value, controlledField, originalValue, handleFieldValueChange]); + + const MemoizedScreenreaderAnnouncement = useMemo( + () => + ScreenreaderAnnouncement( + getScreenReaderAnnouncementText?.(controlledFieldSrValue ?? "") ?? "", + controlledFieldSrValue, + ), + [controlledFieldSrValue, getScreenReaderAnnouncementText], + ); + + return { + SrAnnouncementComponent: MemoizedScreenreaderAnnouncement, + }; +}; + +/** + * Screenreader-only element used to read out controlled field changes + */ +function ScreenreaderAnnouncement( + srText: string, + controlledFieldSrValue?: string, +) { + return ( +
+ + {controlledFieldSrValue && srText} + +
+ ); +} diff --git a/app/domains/kontopfaendung/pkonto/antrag/pages.ts b/app/domains/kontopfaendung/pkonto/antrag/pages.ts index 771564d047..b1197aa871 100644 --- a/app/domains/kontopfaendung/pkonto/antrag/pages.ts +++ b/app/domains/kontopfaendung/pkonto/antrag/pages.ts @@ -1,4 +1,6 @@ +import { setBankNameFromIban } from "~/domains/kontopfaendung/services/setBankNameFromIban"; import type { PagesConfig } from "~/domains/pageSchemas"; +import { translations } from "~/services/translations/translations"; import { checkedRequired } from "~/services/validation/checkedCheckbox"; import { emailSchema } from "~/services/validation/email"; import { ibanSchema } from "~/services/validation/iban"; @@ -34,6 +36,12 @@ export const kontopfaendungPkontoAntragPages = { iban: ibanSchema, bankName: stringRequiredSchema, }, + controlledFieldConfig: { + fieldName: "bankName", + handleFieldValueChange: setBankNameFromIban, + getScreenReaderAnnouncementText: (controlledFieldSrValue: string) => + `${translations.iban.bankIdentified.de}: ${controlledFieldSrValue}`, + }, }, kontoinhaberName: { stepId: "persoenliche-daten/kontoinhaber-name", diff --git a/app/domains/kontopfaendung/services/__test__/setBankNameFromIban.test.ts b/app/domains/kontopfaendung/services/__test__/setBankNameFromIban.test.ts new file mode 100644 index 0000000000..d170610f39 --- /dev/null +++ b/app/domains/kontopfaendung/services/__test__/setBankNameFromIban.test.ts @@ -0,0 +1,95 @@ +import { type FieldApi } from "@rvf/react"; +import { setBankNameFromIban } from "~/domains/kontopfaendung/services/setBankNameFromIban"; + +const { mockIBAN, mockBankName } = vi.hoisted(() => ({ + mockIBAN: "DE02120300000000202051", + mockBankName: "Deutsche Kreditbank Suhl", +})); + +/** + * This swiss IBAN is valid, but doesn't match the German bank name database. + */ +const mockNonMatchingIBAN = "CH0209000000100013997"; + +const mockFieldSetValue = vi.fn(); +const mockFieldValidate = vi.fn(); + +const mockControlledField = { + setValue: mockFieldSetValue, + validate: mockFieldValidate, +} as unknown as FieldApi; + +const mockSetControlledFieldSrValue = vi.fn(); + +vi.mock("~/services/bank/fetchBanks.ts", () => ({ + fetchBanks: vi.fn(() => ({ + [Number(mockIBAN.substring(4, 12))]: mockBankName, + })), +})); + +describe("setBankNameFromIban", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("should update the bankName field if the iban changes and it matches an existing bank", async () => { + await setBankNameFromIban({ + originalValue: "", + value: mockIBAN, + controlledField: mockControlledField, + setControlledFieldSrValue: mockSetControlledFieldSrValue, + }); + vi.runAllTimers(); + + expect(mockFieldSetValue).toHaveBeenCalledWith(mockBankName); + expect(mockSetControlledFieldSrValue).toHaveBeenCalledWith(mockBankName); + expect(mockFieldValidate).toHaveBeenCalled(); + }); + + it("should do nothing if the bank name hasn't changed", async () => { + await setBankNameFromIban({ + originalValue: mockIBAN, + value: mockIBAN, + controlledField: mockControlledField, + setControlledFieldSrValue: mockSetControlledFieldSrValue, + }); + vi.runAllTimers(); + + expect(mockFieldSetValue).not.toHaveBeenCalled(); + expect(mockSetControlledFieldSrValue).not.toHaveBeenCalled(); + expect(mockFieldValidate).not.toHaveBeenCalled(); + }); + + it("should set the bank name field to an empty string if the iban becomes erased", async () => { + await setBankNameFromIban({ + originalValue: mockIBAN, + value: "", + controlledField: mockControlledField, + setControlledFieldSrValue: mockSetControlledFieldSrValue, + }); + vi.runAllTimers(); + + expect(mockFieldSetValue).toHaveBeenCalledWith(""); + expect(mockSetControlledFieldSrValue).toHaveBeenCalledWith(""); + expect(mockFieldValidate).not.toHaveBeenCalled(); + }); + + it("should set the bank name field to an empty string if the iban changes but no bank matches", async () => { + await setBankNameFromIban({ + originalValue: mockIBAN, + value: mockNonMatchingIBAN, + controlledField: mockControlledField, + setControlledFieldSrValue: mockSetControlledFieldSrValue, + }); + vi.runAllTimers(); + + expect(mockFieldSetValue).toHaveBeenCalledWith(""); + expect(mockSetControlledFieldSrValue).toHaveBeenCalledWith(""); + expect(mockFieldValidate).not.toHaveBeenCalled(); + }); +}); diff --git a/app/domains/kontopfaendung/services/setBankNameFromIban.ts b/app/domains/kontopfaendung/services/setBankNameFromIban.ts new file mode 100644 index 0000000000..17bdd914a5 --- /dev/null +++ b/app/domains/kontopfaendung/services/setBankNameFromIban.ts @@ -0,0 +1,43 @@ +import { + type BankData, + bankNameFromIBAN, +} from "~/services/bank/bankNameFromIBAN"; +import { fetchBanks } from "~/services/bank/fetchBanks"; +import { type FieldValueChangeHandler } from "~/domains/pageSchemas"; + +let banks: BankData | undefined = undefined; + +const getBanks = async () => { + banks ??= await fetchBanks(); + + return banks; +}; + +export const setBankNameFromIban: FieldValueChangeHandler = async ({ + originalValue, + value, + controlledField, + setControlledFieldSrValue, +}) => { + if (originalValue !== value) { + const banks = await getBanks(); + if (value && typeof value === "string" && value.length > 0 && banks) { + // Debounce needed to not clobber the screen reader while typing + const timeout = setTimeout(() => { + const matchedBankName = bankNameFromIBAN(value, banks); + if (matchedBankName) { + setControlledFieldSrValue(matchedBankName); + controlledField.setValue(matchedBankName); + controlledField.validate(); + } else { + setControlledFieldSrValue(""); + controlledField.setValue(""); + } + }, 1000); + + return () => clearTimeout(timeout); + } + setControlledFieldSrValue(""); + controlledField?.setValue(""); + } +}; diff --git a/app/domains/pageSchemas.ts b/app/domains/pageSchemas.ts index e377ef2cb2..e99025fe3f 100644 --- a/app/domains/pageSchemas.ts +++ b/app/domains/pageSchemas.ts @@ -5,7 +5,7 @@ import { beratungshilfeAntragPages } from "./beratungshilfe/formular/pages"; import { beratungshilfeVorabcheckPages } from "./beratungshilfe/vorabcheck/pages"; import { flowIdFromPathname, parsePathname, type FlowId } from "./flowIds"; import { kontopfaendungWegweiserPages } from "./kontopfaendung/wegweiser/pages"; -import type { SchemaObject, UserData } from "./userData"; +import type { AllowedUserTypes, SchemaObject, UserData } from "./userData"; import { geldEinklagenFormularPages } from "./geldEinklagen/formular/pages"; import { fluggastrechteFormularPages } from "./fluggastrechte/formular/pages"; import { fluggastrechteVorabcheckPages } from "./fluggastrechte/vorabcheck/pages"; @@ -14,6 +14,9 @@ import { kontopfaendungPkontoAntragPages } from "./kontopfaendung/pkonto/antrag/ import { erbscheinWegweiserPages } from "~/domains/erbschein/wegweiser/pages"; import { erbscheinNachlassgerichtPages } from "./erbschein/nachlassgericht/pages"; import { nachlassErbausschlagungAnfragePages } from "~/domains/nachlass/erbausschlagung/anfrage/pages"; +import { type MaybePromise } from "p-map"; +import { type FieldApi } from "@rvf/react"; +import { type Dispatch, type SetStateAction } from "react"; export const pages: Record = { "/beratungshilfe/vorabcheck": beratungshilfeVorabcheckPages, @@ -69,7 +72,7 @@ export const getAllFieldsFromFlowId = (flowId: FlowId): FormFieldsMap => { return fieldsMap; }; -const getPageConfigOrArrayPageByPathname = (pathname: string) => { +export const getPageConfigOrArrayPageByPathname = (pathname: string) => { const flowId = flowIdFromPathname(pathname); if (!flowId) return undefined; @@ -152,9 +155,34 @@ export function xStateTargetsFromPagesConfig( export type PagesConfig = Record; +export type FieldValueChangeHandlerProps = { + value: AllowedUserTypes; + originalValue: AllowedUserTypes; + controlledField: FieldApi; + setControlledFieldSrValue: Dispatch>; +}; + +export type FieldValueChangeHandler = ( + props: FieldValueChangeHandlerProps, +) => MaybePromise void)>; + +export type ControlledFieldConfig = { + fieldName: string; + handleFieldValueChange: FieldValueChangeHandler; + getScreenReaderAnnouncementText: (controlledFieldSrValue: string) => string; +}; + +export const hasControlledFieldConfig = ( + fieldName: string, + controlledFieldConfig: ControlledFieldConfig | undefined, +) => { + return controlledFieldConfig?.fieldName === fieldName; +}; + type FlowPage = { stepId: string; pageSchema?: SchemaObject; + controlledFieldConfig?: ControlledFieldConfig; readonlyFields?: ReadOnlyFields; }; @@ -163,15 +191,17 @@ type ReadOnlyFields = { shouldMakeReadOnly: (userData: UserData) => boolean; }; -type ArrayPage = { +export type ArrayPage = { pageSchema?: SchemaObject; arrayPages?: Record; + controlledFieldConfig?: ControlledFieldConfig; readonlyFields?: ReadOnlyFields; }; type ArrayParentPage = { stepId: string; pageSchema: SchemaObject; + controlledFieldConfig?: ControlledFieldConfig; arrayPages: Record; }; diff --git a/app/routes/__test__/api.banks.list.test.ts b/app/routes/__test__/api.banks.list.test.ts index 5626479074..c68140c5ac 100644 --- a/app/routes/__test__/api.banks.list.test.ts +++ b/app/routes/__test__/api.banks.list.test.ts @@ -1,6 +1,6 @@ import { type LoaderFunctionArgs } from "react-router"; import { Result } from "true-myth"; -import { type BankData } from "~/components/formElements/inputs/iban/bankNameFromIBAN"; +import { type BankData } from "~/services/bank/bankNameFromIBAN"; import { loader } from "~/routes/api.banks.list"; import { validateCsrfSessionFormless } from "~/services/security/csrf/validatedSession.server"; diff --git a/app/routes/beratungshilfe.zustaendiges-gericht.suche.tsx b/app/routes/beratungshilfe.zustaendiges-gericht.suche.tsx index c265666534..6e22b7f25b 100644 --- a/app/routes/beratungshilfe.zustaendiges-gericht.suche.tsx +++ b/app/routes/beratungshilfe.zustaendiges-gericht.suche.tsx @@ -91,7 +91,6 @@ export default function Index() { { +describe("fetchBanks", () => { afterEach(() => { vi.resetAllMocks(); }); - it("should return undefined if the api call fails", () => { + it("should return undefined if the api call fails", async () => { mockFetch.mockImplementation(() => { throw new Error("API broken :("); }); - const { result } = renderHook(() => useBankData()); - expect(result.current).toBeUndefined(); + expect(await fetchBanks()).toBeUndefined(); expect(mockFetch).toHaveBeenCalledTimes(1); }); @@ -24,10 +22,7 @@ describe("useBankData", () => { ok: false, })); - const { result } = renderHook(() => useBankData()); - await waitFor(() => { - expect(result.current).toBe(undefined); - }); + expect(await fetchBanks()).toBeUndefined(); }); it("should return the bank data if the api call succeeds", async () => { @@ -39,9 +34,6 @@ describe("useBankData", () => { json: () => Promise.resolve(mockBankData), })); - const { result } = renderHook(() => useBankData()); - await waitFor(() => { - expect(result.current).toBe(mockBankData); - }); + expect(await fetchBanks()).toBe(mockBankData); }); }); diff --git a/app/components/formElements/inputs/iban/bankNameFromIBAN.ts b/app/services/bank/bankNameFromIBAN.ts similarity index 100% rename from app/components/formElements/inputs/iban/bankNameFromIBAN.ts rename to app/services/bank/bankNameFromIBAN.ts diff --git a/app/services/bank/fetchBanks.ts b/app/services/bank/fetchBanks.ts new file mode 100644 index 0000000000..9cc515c2d0 --- /dev/null +++ b/app/services/bank/fetchBanks.ts @@ -0,0 +1,12 @@ +import { type BankData } from "./bankNameFromIBAN"; + +export async function fetchBanks(): Promise { + try { + const response = await fetch("/api/banks/list"); + if (response.ok) { + return await response.json(); + } + } catch { + return undefined; + } +} diff --git a/app/services/translations/translations.ts b/app/services/translations/translations.ts index 5039f7a6fd..1a6d9c154b 100644 --- a/app/services/translations/translations.ts +++ b/app/services/translations/translations.ts @@ -40,6 +40,11 @@ export const translations = { de: "Ausblenden", }, }, + iban: { + bankIdentified: { + de: "Bank identifiziert", + }, + }, feedback: { heading: { de: "Haben Sie Fragen oder Anmerkungen?", diff --git a/stories/form/inputs/IbanInput.stories.tsx b/stories/form/inputs/IbanInput.stories.tsx index 56ca0d0d53..37cbf8d794 100644 --- a/stories/form/inputs/IbanInput.stories.tsx +++ b/stories/form/inputs/IbanInput.stories.tsx @@ -1,6 +1,7 @@ import { reactRouterFormContext } from "~/../.storybook/reactRouterFormContext"; import type { Meta, StoryObj } from "@storybook/react-vite"; import IbanInput from "~/components/formElements/inputs/iban/IbanInput"; +import { kontopfaendungPkontoAntragPages } from "~/domains/kontopfaendung/pkonto/antrag/pages"; const meta = { title: "form/inputs/IbanInput", @@ -18,6 +19,8 @@ type Story = StoryObj; export const Default: Story = { args: { name: "iban", + controlledFieldConfig: + kontopfaendungPkontoAntragPages.bankdatenKontodaten.controlledFieldConfig, }, decorators: [(Story) => reactRouterFormContext()], }; diff --git a/stories/form/inputs/NumberInput.stories.tsx b/stories/form/inputs/NumberInput.stories.tsx index 5a704b35b8..223946087b 100644 --- a/stories/form/inputs/NumberInput.stories.tsx +++ b/stories/form/inputs/NumberInput.stories.tsx @@ -46,7 +46,6 @@ export const WithStep: Story = { name: "number-input-step", label: "Number Input with Step", placeholder: "Zahl eingeben...", - step: 0.01, }, decorators: [(Story) => reactRouterFormContext()], };