diff --git a/app/components/formElements/schemaToForm/__test__/getNestedSchema.test.ts b/app/components/formElements/schemaToForm/__test__/getNestedSchema.test.ts index a846d5a87e..d971690c5f 100644 --- a/app/components/formElements/schemaToForm/__test__/getNestedSchema.test.ts +++ b/app/components/formElements/schemaToForm/__test__/getNestedSchema.test.ts @@ -13,6 +13,7 @@ describe("getNestedSchema", () => { innerSchema.or(z.number()), z.union([innerSchema, z.number(), z.boolean()]), schemaOrEmptyString(innerSchema), + z.array(innerSchema), ]; outerSchemas.forEach((outerSchema) => { it(`should unwrap ${outerSchema.def.type}`, () => { diff --git a/app/services/array/__test__/getArraySummaryData.test.ts b/app/services/array/__test__/getArraySummaryData.test.ts index 6654ade379..4630d97830 100644 --- a/app/services/array/__test__/getArraySummaryData.test.ts +++ b/app/services/array/__test__/getArraySummaryData.test.ts @@ -1,8 +1,10 @@ +import z from "zod"; import { getArraySummaryData } from "~/services/array/getArraySummaryData"; +import { ibanSchema } from "~/services/validation/iban"; describe("getArraySummaryData", () => { it("returns undefined when array configuration is missing", () => { - const summaryData = getArraySummaryData([], undefined, {}, []); + const summaryData = getArraySummaryData([], undefined, {}, [], {}); expect(summaryData).toBeUndefined(); }); @@ -35,6 +37,10 @@ describe("getArraySummaryData", () => { kraftfahrzeuge: [{ hasArbeitsweg: "no", wert: "under10000" }], }, [], + { + bankkonten: z.array(z.object({})), + kraftfahrzeuge: z.array(z.object({})), + }, ), ).toEqual({ bankkonten: { @@ -66,6 +72,10 @@ describe("getArraySummaryData", () => { }, { hasBankkonto: "no" }, [], + { + bankkonten: z.array(z.object({})), + kraftfahrzeuge: z.array(z.object({})), + }, ), ).toEqual({}); }); @@ -78,6 +88,9 @@ describe("getArraySummaryData", () => { hasBankkonto: "yes", }, [], + { + bankkonten: z.array(z.object({})), + }, ); expect(actual?.bankkonten?.configuration.disableAddButton).toBe(false); @@ -96,6 +109,9 @@ describe("getArraySummaryData", () => { hasBankkonto: "yes", }, [], + { + bankkonten: z.array(z.object({})), + }, ); expect(actual?.bankkonten?.configuration.disableAddButton).toBe(false); @@ -114,6 +130,9 @@ describe("getArraySummaryData", () => { hasBankkonto: "yes", }, [], + { + bankkonten: z.array(z.object({})), + }, ); expect(actual?.bankkonten?.configuration.disableAddButton).toBe(true); @@ -156,6 +175,9 @@ describe("getArraySummaryData", () => { id: 0, }, ], + { + bankkonten: z.array(z.object({})), + }, ); expect(actual).toEqual({ @@ -185,4 +207,45 @@ describe("getArraySummaryData", () => { }, }); }); + + it("should process special display fields, leaving other fields unmodified", () => { + expect( + getArraySummaryData( + ["bankkonten", "kraftfahrzeuge"], + { bankkonten: bankkontenArrayConfig, kraftfahrzeuge: kfzArrayConfig }, + { + hasBankkonto: "yes", + hasKraftfahrzeug: "yes", + bankkonten: [ + { + iban: "DE02100100100006820101", + }, + ], + kraftfahrzeuge: [{ hasArbeitsweg: "no", wert: "under10000" }], + }, + [], + { + bankkonten: z.array( + z.object({ + iban: ibanSchema, + }), + ), + kraftfahrzeuge: z.array(z.object({})), + }, + ), + ).toEqual({ + bankkonten: { + data: [{ iban: "DE02 1001 0010 0006 8201 01" }], + configuration: { ...bankkontenArrayConfig, disableAddButton: false }, + itemLabels: {}, + buttonLabel: "", + }, + kraftfahrzeuge: { + data: [{ hasArbeitsweg: "no", wert: "under10000" }], + configuration: { ...kfzArrayConfig, disableAddButton: false }, + itemLabels: {}, + buttonLabel: "", + }, + }); + }); }); diff --git a/app/services/array/getArraySummaryData.ts b/app/services/array/getArraySummaryData.ts index 668ff5b36f..214fea2a26 100644 --- a/app/services/array/getArraySummaryData.ts +++ b/app/services/array/getArraySummaryData.ts @@ -1,7 +1,16 @@ import { type HeadingProps } from "~/components/common/Heading"; -import type { ArrayData, UserData } from "~/domains/userData"; +import type { + AllowedUserTypes, + ArrayData, + SchemaObject, + UserData, +} from "~/domains/userData"; import type { ArrayConfigServer, ArrayConfigClient } from "."; import { type StrapiContentComponent } from "../cms/models/formElements/StrapiContentComponent"; +import { type ZodType } from "zod"; +import { isZodObject } from "~/components/formElements/schemaToForm/renderZodObject"; +import { getNestedSchema } from "~/components/formElements/schemaToForm/getNestedSchema"; +import { processSpecialFieldDisplay } from "~/services/processSpecialFieldDisplay"; export type ItemLabels = Record; @@ -20,11 +29,32 @@ export type ArraySummaryData = > | undefined; +function encodeSpecialFields( + inputArray: ArrayData, + arraySchema: SchemaObject[string], +): ArrayData { + const innerSchema = getNestedSchema(arraySchema); + if (!isZodObject(innerSchema)) { + return inputArray; + } + let encodedData = inputArray; + Object.entries(innerSchema.shape).forEach( + ([fieldName, schema]: [string, ZodType]) => { + encodedData = encodedData.map((item) => ({ + ...item, + [fieldName]: processSpecialFieldDisplay(item[fieldName], schema), + })); + }, + ); + return encodedData; +} + export function getArraySummaryData( categories: string[], arrayConfigurations: Record | undefined, userData: UserData, content: StrapiContentComponent[], + relevantPageSchemas: SchemaObject, ): ArraySummaryData { if (!arrayConfigurations) { return undefined; @@ -42,7 +72,9 @@ export function getArraySummaryData( const disableAddButton = arrayConfiguration.shouldDisableAddButton?.(userData) ?? false; const possibleArray = userData[category]; - const data = Array.isArray(possibleArray) ? possibleArray : []; + const data = Array.isArray(possibleArray) + ? encodeSpecialFields(possibleArray, relevantPageSchemas[category]) + : []; const arraySummaryCategoryContent = content .filter((value) => value.__component === "page.array-summary") diff --git a/app/services/flow/formular/contentData/__test__/getContentData.test.ts b/app/services/flow/formular/contentData/__test__/getContentData.test.ts index 1e0bfa9c40..b4936564eb 100644 --- a/app/services/flow/formular/contentData/__test__/getContentData.test.ts +++ b/app/services/flow/formular/contentData/__test__/getContentData.test.ts @@ -45,6 +45,7 @@ const mockTranslations = { const mockBuildFlowController = { getRootMeta: vi.fn().mockReturnValue(undefined), + getConfig: vi.fn().mockReturnValue(""), isFinal: vi.fn().mockReturnValue(false), getPrevious: vi.fn().mockReturnValue(""), stepStates: vi.fn().mockReturnValue(undefined), diff --git a/app/services/flow/formular/contentData/getContentData.ts b/app/services/flow/formular/contentData/getContentData.ts index e8085f76b8..22462371ef 100644 --- a/app/services/flow/formular/contentData/getContentData.ts +++ b/app/services/flow/formular/contentData/getContentData.ts @@ -1,4 +1,5 @@ import { getArraySummaryData } from "~/services/array/getArraySummaryData"; +import pick from "lodash/pick"; import { type CMSContent } from "~/services/flow/formular/buildCmsContentAndTranslations"; import { type StepState, @@ -17,7 +18,8 @@ import { stateIsCurrent, } from "~/services/navigation/navState"; import { type StepStepper } from "~/components/navigation/types"; -import { getPageSchema } from "~/domains/pageSchemas"; +import { getAllPageSchemaByFlowId, getPageSchema } from "~/domains/pageSchemas"; +import { type FlowId } from "~/domains/flowIds"; type ContentParameters = { cmsContent: CMSContent; @@ -60,12 +62,17 @@ export const getContentData = ( const arrayCategories = cmsContent.content .filter((value) => value.__component === "page.array-summary") .map((arraySummary) => arraySummary.category); + const relevantPageSchemas = pick( + getAllPageSchemaByFlowId(flowController.getConfig()?.id as FlowId), + arrayCategories, + ); return getArraySummaryData( arrayCategories, flowController.getRootMeta()?.arrays, userDataWithPageData, cmsContent.content, + relevantPageSchemas, ); }, getFormElements: () => { diff --git a/app/services/processSpecialFieldDisplay.ts b/app/services/processSpecialFieldDisplay.ts new file mode 100644 index 0000000000..3068077870 --- /dev/null +++ b/app/services/processSpecialFieldDisplay.ts @@ -0,0 +1,17 @@ +import z from "zod"; +import { extractZodDescription } from "~/components/formElements/schemaToForm/renderSchemaBasedFormElement"; +import { type AllowedUserTypes, type SchemaObject } from "~/domains/userData"; +import { ibanZodDescription } from "~/services/validation/iban"; + +export const specialFieldsToEncode = new Set([ibanZodDescription]); + +export function processSpecialFieldDisplay( + value: T, + schema?: SchemaObject[string], +): T { + const schemaDescription = extractZodDescription(schema ?? z.NEVER); + if (schemaDescription && specialFieldsToEncode.has(schemaDescription)) { + return schema?.encode(value) as T; + } + return value; +} diff --git a/app/services/summary/__test__/fieldEntryCreation.test.ts b/app/services/summary/__test__/fieldEntryCreation.test.ts index 5fd3875019..779b4c9f2c 100644 --- a/app/services/summary/__test__/fieldEntryCreation.test.ts +++ b/app/services/summary/__test__/fieldEntryCreation.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { createFieldEntry, processBoxFields } from "../fieldEntryCreation"; import type { UserData } from "~/domains/userData"; +import z from "zod"; describe("fieldEntryCreation", () => { describe("createFieldEntry", () => { @@ -24,6 +25,7 @@ describe("fieldEntryCreation", () => { userData, mockFieldQuestions, "/beratungshilfe/antrag/persoenliche-daten/name", + z.string(), ); expect(result).toEqual( @@ -46,6 +48,7 @@ describe("fieldEntryCreation", () => { userData, mockFieldQuestions, "/beratungshilfe/antrag/persoenliche-daten/name", + z.string(), ); expect(result).toEqual( @@ -70,6 +73,7 @@ describe("fieldEntryCreation", () => { userData, mockFieldQuestions, "/beratungshilfe/antrag/finanzielle-angaben/kinder/kinder/name", + z.string(), ); expect(result).toEqual( @@ -95,6 +99,7 @@ describe("fieldEntryCreation", () => { userData, mockFieldQuestions, "/beratungshilfe/antrag/finanzielle-angaben/einkommen/einkommen", + z.string(), ); expect(result.question).toBe("Welche Berufsart haben Sie?"); diff --git a/app/services/summary/__test__/processSpecialFieldDisplay.test.ts b/app/services/summary/__test__/processSpecialFieldDisplay.test.ts new file mode 100644 index 0000000000..a0e9d4a4a5 --- /dev/null +++ b/app/services/summary/__test__/processSpecialFieldDisplay.test.ts @@ -0,0 +1,26 @@ +import z from "zod"; +import { processSpecialFieldDisplay } from "~/services/processSpecialFieldDisplay"; +import { ibanZodDescription } from "~/services/validation/iban"; +import { schemaOrEmptyString } from "~/services/validation/schemaOrEmptyString"; + +describe("processSpecialFieldDisplay", () => { + it("should call the encode method of a special field schema if present", () => { + const mockEncode = vi.fn((str) => str + " encoded"); + const specialFieldSchema = schemaOrEmptyString( + z + .codec(z.string(), z.string(), { + encode: mockEncode, + decode: vi.fn(), + }) + .describe(ibanZodDescription), + ); + const result = processSpecialFieldDisplay("test", specialFieldSchema); + expect(mockEncode).toHaveBeenCalled(); + expect(result).toBe("test encoded"); + }); + + it("should return the unmodified value if the schema isn't a special field", () => { + const result = processSpecialFieldDisplay("unmodified", z.string()); + expect(result).toBe("unmodified"); + }); +}); diff --git a/app/services/summary/fieldEntryCreation.ts b/app/services/summary/fieldEntryCreation.ts index 5680903cec..97a1759cce 100644 --- a/app/services/summary/fieldEntryCreation.ts +++ b/app/services/summary/fieldEntryCreation.ts @@ -1,10 +1,16 @@ -import type { AllowedUserTypes, UserData } from "~/domains/userData"; +import type { + AllowedUserTypes, + SchemaObject, + UserData, +} from "~/domains/userData"; import type { FieldItem } from "./types"; import { formatFieldValue } from "./formatFieldValue"; import { getUserDataFieldLabel } from "./templateReplacement"; import { createArrayEditUrl } from "./arrayFieldProcessing"; import { parseArrayField } from "./fieldParsingUtils"; import { findStepIdForField } from "./getFormQuestions"; +import { getRelevantPageSchemasForStepId } from "~/domains/pageSchemas"; +import { type FlowId } from "~/domains/flowIds"; export function createFieldEntry( fieldName: string, @@ -14,6 +20,7 @@ export function createFieldEntry( { question?: string; options?: Array<{ text: string; value: string }> } >, representativeStepId: string, + schema: SchemaObject[string], ): FieldItem { const fieldInfo = parseArrayField(fieldName); const isArrayItem = fieldInfo.isArrayField; @@ -52,7 +59,7 @@ export function createFieldEntry( const answer = value == undefined || value === "" ? "Keine Angabe" // need to get this from CMS for translations - : formatFieldValue(value, fieldQuestion?.options); + : formatFieldValue(value, fieldQuestion?.options, schema); let editUrl: string | undefined = undefined; if (representativeStepId) { @@ -80,12 +87,23 @@ export function processBoxFields( { question?: string; options?: Array<{ text: string; value: string }> } >, fieldToStepMapping: Record, - flowId: string, + flowId: FlowId, ): FieldItem[] { return fields.map((fieldName) => { const stepId = findStepIdForField(fieldName, fieldToStepMapping); const fullStepId = stepId ? `${flowId}${stepId}` : ""; + const pageConfig = getRelevantPageSchemasForStepId(flowId, stepId ?? ""); + const pageSchemas = Object.entries(pageConfig).reduce( + (prev, [, item]) => ({ ...prev, ...item.pageSchema }), + {} as SchemaObject, + ); - return createFieldEntry(fieldName, userData, fieldQuestions, fullStepId); + return createFieldEntry( + fieldName, + userData, + fieldQuestions, + fullStepId, + pageSchemas[fieldName], + ); }); } diff --git a/app/services/summary/formatFieldValue.ts b/app/services/summary/formatFieldValue.ts index 8ca6f16e47..382f2d751f 100644 --- a/app/services/summary/formatFieldValue.ts +++ b/app/services/summary/formatFieldValue.ts @@ -1,4 +1,9 @@ -import type { AllowedUserTypes, ObjectType } from "~/domains/userData"; +import type { + AllowedUserTypes, + ObjectType, + SchemaObject, +} from "~/domains/userData"; +import { processSpecialFieldDisplay } from "~/services/processSpecialFieldDisplay"; function formatDateObject(valueObj: Record): string { const day = String(valueObj.day).padStart(2, "0"); @@ -73,6 +78,7 @@ function translateWithOptions( export function formatFieldValue( value: AllowedUserTypes, options?: Array<{ text: string; value: string }>, + schema?: SchemaObject[string], ): string { if (value == null || Array.isArray(value)) { return ""; @@ -96,6 +102,8 @@ export function formatFieldValue( } else { formattedValue = ""; } + + formattedValue = processSpecialFieldDisplay(formattedValue, schema); } return translateWithOptions(formattedValue, options); diff --git a/app/services/validation/iban.ts b/app/services/validation/iban.ts index 62a1262376..b7044f36c2 100644 --- a/app/services/validation/iban.ts +++ b/app/services/validation/iban.ts @@ -6,10 +6,13 @@ const isIbanCheck: typeof isIBAN = isIBAN.default ?? isIBAN; export const ibanZodDescription = "iban"; +export type ZodIban = typeof ibanSchema; + export const ibanSchema = z - .string() - .toUpperCase() - .transform((ibanInput) => ibanInput.replaceAll(" ", "")) + .codec(z.string(), z.string(), { + decode: (iban) => iban.replaceAll(" ", "").toLocaleUpperCase(), + encode: (iban) => formatIban(iban), + }) .refine(isIbanCheck, { message: "invalid_iban_format" }) .describe(ibanZodDescription);