From 0ade5c081eaad6561b14fc0b86d2977d9bb8b9b8 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Tue, 11 Jun 2024 10:39:25 +0200 Subject: [PATCH] add tests, tidy up --- .../@planx/components/List/Public/Context.tsx | 46 +++------ .../components/List/Public/index.test.tsx | 68 ++++++++++++- .../src/@planx/components/List/model.ts | 2 +- .../src/@planx/components/List/utils.test.ts | 77 +++++++++++++++ .../src/@planx/components/List/utils.ts | 98 +++++++++++++++---- 5 files changed, 237 insertions(+), 54 deletions(-) create mode 100644 editor.planx.uk/src/@planx/components/List/utils.test.ts diff --git a/editor.planx.uk/src/@planx/components/List/Public/Context.tsx b/editor.planx.uk/src/@planx/components/List/Public/Context.tsx index af4792df0e..68920e56b2 100644 --- a/editor.planx.uk/src/@planx/components/List/Public/Context.tsx +++ b/editor.planx.uk/src/@planx/components/List/Public/Context.tsx @@ -18,7 +18,11 @@ import { Schema, UserData, } from "../model"; -import { flatten } from "../utils"; +import { + flatten, + sumIdenticalUnits, + sumIdenticalUnitsByDevelopmentType, +} from "../utils"; interface ListContextValue { schema: Schema; @@ -132,7 +136,7 @@ export const ListProvider: React.FC = (props) => { userData: getInitialValues(), }, onSubmit: (values) => { - // defaultPassportData is used when coming "back" + // defaultPassportData (array) is used when coming "back" const defaultPassportData = makeData(props, values.userData)?.["data"]; // flattenedPassportData makes individual list items compatible with Calculate components @@ -141,40 +145,22 @@ export const ListProvider: React.FC = (props) => { // basic example of general summary stats we can add onSubmit: // 1. count of items/responses // 2. if the schema includes a field that sets fn = "identicalUnits", sum of total units - // 3. if the schema includes a field that sets fn = "development", sum of total units by development "val" - let sumIdenticalUnits = 0; - defaultPassportData[`${props.fn}`].map( - (item) => (sumIdenticalUnits += parseInt(item?.identicalUnits)), + // 3. if the schema includes a field that sets fn = "development" & fn = "identicalUnits", sum of total units by development "val" + const totalUnits = sumIdenticalUnits(props.fn, defaultPassportData); + const totalUnitsByDevelopmentType = sumIdenticalUnitsByDevelopmentType( + props.fn, + defaultPassportData, ); - const sumIdenticalUnitsByDevelopmentType: Record = { - newBuild: 0, - changeOfUseFrom: 0, - changeOfUseTo: 0, - }; - defaultPassportData[`${props.fn}`].map( - (item) => - (sumIdenticalUnitsByDevelopmentType[`${item?.development}`] += - parseInt(item?.identicalUnits)), - ); - const sumIdenticalUnitsByDevelopmentTypeSummary: Record = - {}; - Object.entries(sumIdenticalUnitsByDevelopmentType).forEach(([k, v]) => { - if (v > 0) { - sumIdenticalUnitsByDevelopmentTypeSummary[ - `${props.fn}.total.units.development.${k}` - ] = v; - } - }); - const summaries = { [`${props.fn}.total.listItems`]: defaultPassportData[`${props.fn}`].length, - ...(sumIdenticalUnits > 0 && { - [`${props.fn}.total.units`]: sumIdenticalUnits, + ...(totalUnits > 0 && { + [`${props.fn}.total.units`]: totalUnits, }), - ...(Object.keys(sumIdenticalUnitsByDevelopmentTypeSummary).length > 0 && - sumIdenticalUnitsByDevelopmentTypeSummary), + ...(totalUnits > 0 && + Object.keys(totalUnitsByDevelopmentType).length > 0 && + totalUnitsByDevelopmentType), }; handleSubmit?.({ diff --git a/editor.planx.uk/src/@planx/components/List/Public/index.test.tsx b/editor.planx.uk/src/@planx/components/List/Public/index.test.tsx index 248d83dc80..2f72a1e8f9 100644 --- a/editor.planx.uk/src/@planx/components/List/Public/index.test.tsx +++ b/editor.planx.uk/src/@planx/components/List/Public/index.test.tsx @@ -4,9 +4,15 @@ import { cloneDeep, merge } from "lodash"; import React from "react"; import { axe, setup } from "testUtils"; +import { UserResponse } from "../model"; import ListComponent, { Props } from "../Public"; import { GenericUnitsTest } from "../schemas/GenericUnitsTest"; import { Zoo } from "../schemas/Zoo"; +import { + flatten, + sumIdenticalUnits, + sumIdenticalUnitsByDevelopmentType, +} from "../utils"; const mockProps: Props = { fn: "mockFn", @@ -57,7 +63,36 @@ const mockPropsUnits: Props = { const mockPayloadUnits = { data: { - "proposal.units.residential": [], + "proposal.units.residential": [ + { + development: "newBuild", + garden: "Yes", + identicalUnits: 1, + }, + { + development: "newBuild", + garden: "No", + identicalUnits: 2, + }, + { + development: "changeOfUseTo", + garden: "No", + identicalUnits: 2, + }, + ], + "proposal.units.residential.one.development": "newBuild", + "proposal.units.residential.one.garden": "Yes", + "proposal.units.residential.one.identicalUnits": 1, + "proposal.units.residential.two.development": "newBuild", + "proposal.units.residential.two.garden": "No", + "proposal.units.residential.two.identicalUnits": 2, + "proposal.units.residential.three.development": "changeOfUseTo", + "proposal.units.residential.three.garden": "No", + "proposal.units.residential.three.identicalUnits": 2, + "proposal.units.residential.total.listItems": 3, + "proposal.units.residential.total.units": 5, + "proposal.units.residential.total.units.newBuid": 3, + "proposal.units.residential.total.units.changeOfUseTo": 2, }, }; @@ -415,9 +450,38 @@ describe("Payload generation", () => { const { getByTestId, user } = setup( , ); + + const saveButton = screen.getByRole("button", { name: /Save/ }); const addItemButton = getByTestId("list-add-button"); + const developmentSelect = screen.getByRole("combobox"); + const gardenYesRadio = screen.getAllByRole("radio")[0]; + const gardenNoRadio = screen.getAllByRole("radio")[1]; + const unitsNumberInput = screen.getByLabelText(/identical units/); + + // Response 1 + await user.click(developmentSelect); + await user.click(screen.getByRole("option", { name: /New build/ })); + await user.click(gardenYesRadio); + await user.type(unitsNumberInput, "1"); + await user.click(saveButton); + + // Response 2 + await user.click(addItemButton); + await user.click(developmentSelect); + await user.click(screen.getByRole("option", { name: /New build/ })); + await user.click(gardenNoRadio); + await user.type(unitsNumberInput, "2"); + await user.click(saveButton); - // fill in three unique responses + // Response 3 + await user.click(addItemButton); + await user.click(developmentSelect); + await user.click( + screen.getByRole("option", { name: /Change of use to a home/ }), + ); + await user.click(gardenNoRadio); + await user.type(unitsNumberInput, "2"); + await user.click(saveButton); await user.click(screen.getByTestId("continue-button")); diff --git a/editor.planx.uk/src/@planx/components/List/model.ts b/editor.planx.uk/src/@planx/components/List/model.ts index d35532cf9a..89ad950cfc 100644 --- a/editor.planx.uk/src/@planx/components/List/model.ts +++ b/editor.planx.uk/src/@planx/components/List/model.ts @@ -62,7 +62,7 @@ export interface Schema { max?: number; } -type UserResponse = Record; +export type UserResponse = Record; export type UserData = { userData: UserResponse[] }; diff --git a/editor.planx.uk/src/@planx/components/List/utils.test.ts b/editor.planx.uk/src/@planx/components/List/utils.test.ts new file mode 100644 index 0000000000..5fdb8abc68 --- /dev/null +++ b/editor.planx.uk/src/@planx/components/List/utils.test.ts @@ -0,0 +1,77 @@ +import { UserResponse } from "./model"; +import { + flatten, + sumIdenticalUnits, + sumIdenticalUnitsByDevelopmentType, +} from "./utils"; + +describe("passport data shape", () => { + it("flattens list items", async () => { + const defaultPassportData = { + mockFn: [ + { + age: 10, + cuteness: "Very", + email: "richard.parker@pi.com", + name: "Richard Parker", + size: "Medium", + }, + { + age: 10, + cuteness: "Very", + email: "richard.parker@pi.com", + name: "Richard Parker", + size: "Medium", + }, + ], + }; + + expect(flatten(defaultPassportData)).toEqual({ + "mockFn.one.age": 10, + "mockFn.one.cuteness": "Very", + "mockFn.one.email": "richard.parker@pi.com", + "mockFn.one.name": "Richard Parker", + "mockFn.one.size": "Medium", + "mockFn.two.age": 10, + "mockFn.two.cuteness": "Very", + "mockFn.two.email": "richard.parker@pi.com", + "mockFn.two.name": "Richard Parker", + "mockFn.two.size": "Medium", + }); + }); + + it("adds summary stats when applicable fields are set", async () => { + const defaultPassportData = { + "proposal.units.residential": [ + { + development: "newBuild", + garden: "Yes", + identicalUnits: 1, + }, + { + development: "newBuild", + garden: "No", + identicalUnits: 2, + }, + { + development: "changeOfUseTo", + garden: "No", + identicalUnits: 2, + }, + ], + } as unknown as Record; + + expect( + sumIdenticalUnits("proposal.units.residential", defaultPassportData), + ).toEqual(5); + expect( + sumIdenticalUnitsByDevelopmentType( + "proposal.units.residential", + defaultPassportData, + ), + ).toEqual({ + "proposal.units.residential.total.units.development.newBuild": 3, + "proposal.units.residential.total.units.development.changeOfUseTo": 2, + }); + }); +}); diff --git a/editor.planx.uk/src/@planx/components/List/utils.ts b/editor.planx.uk/src/@planx/components/List/utils.ts index 220c5fc1f5..b5c8226afa 100644 --- a/editor.planx.uk/src/@planx/components/List/utils.ts +++ b/editor.planx.uk/src/@planx/components/List/utils.ts @@ -1,7 +1,77 @@ -import { QuestionField, Schema } from "./model"; +import { QuestionField, Schema, UserResponse } from "./model"; -// Flattens nested object so we can output passport variables like `{listFn}.{itemIndexAsText}.{fieldFn}` -// Adapted from https://gist.github.com/penguinboy/762197 +/** + * In the case of "question" fields, ensure the displayed value reflects option "text", rather than "val" as recorded in passport + * @param value - the `val` or `text` of an Option defined in the schema's fields + * @param schema - the Schema object + * @returns string - the `text` for the given value `val`, or the original value + */ +export function formatSchemaDisplayValue(value: string, schema: Schema) { + const questionFields = schema.fields.filter( + (field) => field.type === "question", + ) as QuestionField[]; + const matchingField = questionFields?.find((field) => + field.data.options.some((option) => option.data.val === value), + ); + const matchingOption = matchingField?.data.options.find( + (option) => option.data.val === value, + ); + + // If we found a "val" match, return its text, else just return the value as passed in + return matchingOption?.data?.text || value; +} + +/** + * If the schema includes a field that sets fn = "identicalUnits", sum of total units + * @param fn - passport key of current List + * @param passportData - default passport data format for the List + * @returns - sum of all units, or 0 if field not set + */ +export function sumIdenticalUnits( + fn: string, + passportData: Record, +): number { + let sum = 0; + passportData[`${fn}`].map((item) => (sum += parseInt(item?.identicalUnits))); + return sum; +} + +/** + * If the schema includes fields that set fn = "development" and fn = "identicalUnits", sum of total units by development option "val" + * @param fn - passport key of current List + * @param passportData - default passport data format for the List + * @returns - sum of all units by development type, or empty object if fields not set + */ +export function sumIdenticalUnitsByDevelopmentType( + fn: string, + passportData: Record, +): Record { + // Sum identical units by development type (read option `val` from Schema in future?) + const baseSums: Record = { + newBuild: 0, + changeOfUseFrom: 0, + changeOfUseTo: 0, + }; + passportData[`${fn}`].map( + (item) => + (baseSums[`${item?.development}`] += parseInt(item?.identicalUnits)), + ); + + // Format property names for passport, and filter out any entries with default sum = 0 + const formattedSums: Record = {}; + Object.entries(baseSums).forEach(([k, v]) => { + if (v > 0) { + formattedSums[`${fn}.total.units.development.${k}`] = v; + } + }); + + return formattedSums; +} + +/** + * Flattens nested object so we can output passport variables like `{listFn}.{itemIndexAsText}.{fieldFn}` + * Adapted from https://gist.github.com/penguinboy/762197 + */ export function flatten>( object: T, path: string | null = null, @@ -30,8 +100,6 @@ export function flatten>( }, {} as T); } -// Convert a whole number up to 99 to a spelled-out word (eg 34 => 'thirtyfour') -// Adapted from https://stackoverflow.com/questions/5529934/javascript-numbers-to-words const ones = [ "", "one", @@ -80,6 +148,10 @@ function convertTens(num: number): string { } } +/** + * Convert a whole number up to 99 to a spelled-out word (eg 34 => 'thirtyfour') + * Adapted from https://stackoverflow.com/questions/5529934/javascript-numbers-to-words + */ function convertNumberToText(num: number): string { if (num == 0) { return "zero"; @@ -87,19 +159,3 @@ function convertNumberToText(num: number): string { return convertTens(num); } } - -// In the case of "question" fields, ensure the displayed value reflects option "text", rather than "val" as recorded in passport -export function formatSchemaDisplayValue(value: string, schema: Schema) { - const questionFields = schema.fields.filter( - (field) => field.type === "question", - ) as QuestionField[]; - const matchingField = questionFields?.find((field) => - field.data.options.some((option) => option.data.val === value), - ); - const matchingOption = matchingField?.data.options.find( - (option) => option.data.val === value, - ); - - // If we found a "val" match, return its text, else just return the value as passed in - return matchingOption?.data?.text || value; -}