diff --git a/editor.planx.uk/src/@planx/components/List/Public/Context.tsx b/editor.planx.uk/src/@planx/components/List/Public/Context.tsx new file mode 100644 index 0000000000..1f940805fb --- /dev/null +++ b/editor.planx.uk/src/@planx/components/List/Public/Context.tsx @@ -0,0 +1,84 @@ +import React, { createContext, ReactNode, useContext, useState } from "react"; + +import { generateNewItem, Schema, UserData } from "../model"; + +interface ListContextValue { + schema: Schema; + activeIndex: number | undefined; + userData: UserData; + addNewItem: () => void; + saveItem: (index: number, updatedItem: UserData[0]) => void; + removeItem: (index: number) => void; + editItem: (index: number) => void; + cancelEditItem: () => void; +} + +interface ListProviderProps { + children: ReactNode; + schema: Schema; +} + +const ListContext = createContext(undefined); + +export const ListProvider: React.FC = ({ + children, + schema, +}) => { + const [activeIndex, setActiveIndex] = useState(0); + const [userData, setUserData] = useState( + schema.min === 0 ? [] : [generateNewItem(schema)], + ); + + const addNewItem = () => { + setUserData([...userData, generateNewItem(schema)]); + setActiveIndex((prev) => (prev === undefined ? 0 : prev + 1)); + }; + + const saveItem = (index: number, updatedItem: UserData[0]) => { + setUserData((prev) => + prev.map((item, i) => (i === index ? updatedItem : item)), + ); + }; + + const editItem = (index: number) => setActiveIndex(index); + + const removeItem = (index: number) => { + if (activeIndex && index < activeIndex) { + // If item is before currently active card, retain active card + setActiveIndex((prev) => (prev === undefined ? 0 : prev - 1)); + } else if (index === activeIndex || index === 0) { + // If item is currently in Edit mode, exit Edit mode + cancelEditItem(); + } + + // Remove item from userData + setUserData((prev) => prev.filter((_, i) => i !== index)); + }; + + const cancelEditItem = () => setActiveIndex(undefined); + + return ( + + {children} + + ); +}; + +export const useListContext = (): ListContextValue => { + const context = useContext(ListContext); + if (!context) { + throw new Error("useListContext must be used within a ListProvider"); + } + return context; +}; 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 d4c1d036b4..16d17c1452 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 @@ -1,3 +1,5 @@ +import { within } from "@testing-library/react"; +import { cloneDeep, merge } from "lodash"; import React from "react"; import { axe, setup } from "testUtils"; @@ -99,9 +101,269 @@ describe("Navigating back", () => { }); describe("Building a list", () => { - test.todo("Adding an item"); - test.todo("Editing an item"); - test.todo("Removing an item"); + it("does not display a default item if the schema has no required minimum", () => { + const mockWithMinZero = merge(cloneDeep(mockProps), { schema: { min: 0 } }); + const { queryByRole, getByRole } = setup( + , + ); + + // No card present + const activeListHeading = queryByRole("heading", { + level: 2, + name: "Animal 1", + }); + expect(activeListHeading).toBeNull(); + + // Button is present allow additional items to be added + const addItemButton = getByRole("button", { + name: /Add a new animal type/, + }); + expect(addItemButton).toBeInTheDocument(); + expect(addItemButton).not.toBeDisabled(); + }); + + it("displays a default item if the schema has a required minimum", () => { + const { getByRole, queryByLabelText } = setup( + , + ); + + // Card present... + const activeListHeading = getByRole("heading", { + level: 2, + name: "Animal 1", + }); + expect(activeListHeading).toBeInTheDocument(); + + // ...with active fields + const inputField = queryByLabelText(/What's their name?/); + expect(inputField).toBeInTheDocument(); + expect(inputField).not.toBeDisabled(); + }); + + test("Adding an item", async () => { + const { getAllByRole, getByRole, user } = setup( + , + ); + + let cards = getAllByRole("heading", { level: 2 }).map((el) => + el.closest("div"), + ); + expect(cards).toHaveLength(1); + + const addItemButton = getByRole("button", { + name: /Add a new animal type/, + }); + await user.click(addItemButton); + + // Item successfully added + cards = getAllByRole("heading", { level: 2 }).map((el) => + el.closest("div"), + ); + expect(cards).toHaveLength(2); + + // Old item is inactive + const [firstCard, secondCard] = cards; + expect(firstCard).not.toBeNull(); + expect( + within(firstCard!).queryByLabelText(/What's their name?/), + ).toBeNull(); + + // New item is active + expect(secondCard).not.toBeNull(); + expect( + within(secondCard!).getByLabelText(/What's their name?/), + ).toBeInTheDocument(); + }); + + test("Editing an item", async () => { + // Setup three cards + const { getAllByRole, getByRole, user, findByLabelText } = setup( + , + ); + + const addItemButton = getByRole("button", { + name: /Add a new animal type/, + }); + await user.click(addItemButton); + await user.click(addItemButton); + + let cards = getAllByRole("heading", { level: 2 }).map((el) => + el.closest("div"), + ); + expect(cards).toHaveLength(3); + + let [firstCard, secondCard, thirdCard] = cards; + + // Final card is currently active + expect(thirdCard).not.toBeNull(); + expect( + within(thirdCard!).getByLabelText(/What's their name?/), + ).toBeInTheDocument(); + + // Hitting "cancel" takes us out of Edit mode + const thirdCardCancelButton = within(thirdCard!).getByRole("button", { + name: /Cancel/, + }); + await user.click(thirdCardCancelButton); + + cards = getAllByRole("heading", { level: 2 }).map((el) => + el.closest("div"), + ); + [firstCard, secondCard, thirdCard] = cards; + + // No cards currently active + expect( + within(firstCard!).queryByLabelText(/What's their name?/), + ).toBeNull(); + expect( + within(secondCard!).queryByLabelText(/What's their name?/), + ).toBeNull(); + expect( + within(thirdCard!).queryByLabelText(/What's their name?/), + ).toBeNull(); + + // All card in view only / summary mode + expect(within(firstCard!).getByText(/What's their name?/)).toBeVisible(); + expect(within(secondCard!).getByText(/What's their name?/)).toBeVisible(); + expect(within(thirdCard!).getByText(/What's their name?/)).toBeVisible(); + + // Hit "Edit" on second card + const secondCardEditButton = within(secondCard!).getByRole("button", { + name: /Edit/, + }); + await user.click(secondCardEditButton); + + cards = getAllByRole("heading", { level: 2 }).map((el) => + el.closest("div"), + ); + [firstCard, secondCard, thirdCard] = cards; + + // Second card now editable + expect( + within(secondCard!).getByLabelText(/What's their name?/), + ).toBeInTheDocument(); + }); + + test("Removing an item when all cards are inactive", async () => { + // Setup three cards + const { getAllByRole, getByRole, user, getByLabelText, queryAllByRole } = + setup(); + + const addItemButton = getByRole("button", { + name: /Add a new animal type/, + }); + await user.click(addItemButton); + await user.click(addItemButton); + + let cards = getAllByRole("heading", { level: 2 }).map((el) => + el.closest("div"), + ); + expect(cards).toHaveLength(3); + + let [firstCard, secondCard, thirdCard] = cards; + + const thirdCardCancelButton = within(thirdCard!).getByRole("button", { + name: /Cancel/, + }); + await user.click(thirdCardCancelButton); + + [firstCard, secondCard, thirdCard] = getAllByRole("heading", { + level: 2, + }).map((el) => el.closest("div")); + + // Remove third card + const thirdCardRemoveButton = within(thirdCard!).getByRole("button", { + name: /Remove/, + }); + await user.click(thirdCardRemoveButton); + cards = getAllByRole("heading", { level: 2 }).map((el) => + el.closest("div"), + ); + expect(cards).toHaveLength(2); + + [firstCard, secondCard] = getAllByRole("heading", { level: 2 }).map((el) => + el.closest("div"), + ); + + // Previous items remain inactive + expect( + within(firstCard!).queryByLabelText(/What's their name?/), + ).toBeNull(); + expect( + within(secondCard!).queryByLabelText(/What's their name?/), + ).toBeNull(); + + // Remove second card + const secondCardRemoveButton = within(secondCard!).getByRole("button", { + name: /Remove/, + }); + await user.click(secondCardRemoveButton); + cards = getAllByRole("heading", { level: 2 }).map((el) => + el.closest("div"), + ); + expect(cards).toHaveLength(1); + + [firstCard] = getAllByRole("heading", { level: 2 }).map((el) => + el.closest("div"), + ); + + // Previous items remain inactive + expect( + within(firstCard!).queryByLabelText(/What's their name?/), + ).toBeNull(); + + // Remove first card + const firstCardRemoveButton = within(firstCard!).getByRole("button", { + name: /Remove/, + }); + await user.click(firstCardRemoveButton); + cards = queryAllByRole("heading", { level: 2 }).map((el) => + el.closest("div"), + ); + expect(cards).toHaveLength(0); + + // Add item back + await user.click(addItemButton); + + // This is now editable and active + const newFirstCardInput = getByLabelText(/What's their name?/); + expect(newFirstCardInput).toBeInTheDocument(); + }); + + test("Removing an item when another card is active", async () => { + // Setup two cards + const { getAllByRole, getByRole, user, getByLabelText, queryAllByRole } = + setup(); + + const addItemButton = getByRole("button", { + name: /Add a new animal type/, + }); + await user.click(addItemButton); + + const [firstCard, secondCard] = getAllByRole("heading", { level: 2 }).map( + (el) => el.closest("div"), + ); + + // Second card is active + expect( + within(secondCard!).getByLabelText(/What's their name?/), + ).toBeInTheDocument(); + + // Remove first + const firstCardRemoveButton = within(firstCard!).getByRole("button", { + name: /Remove/, + }); + await user.click(firstCardRemoveButton); + const cards = getAllByRole("heading", { level: 2 }).map((el) => + el.closest("div"), + ); + expect(cards).toHaveLength(1); + + // First card is active + expect( + within(cards[0]!).getByLabelText(/What's their name?/), + ).toBeInTheDocument(); + }); }); describe("Form validation and error handling", () => { diff --git a/editor.planx.uk/src/@planx/components/List/Public/index.tsx b/editor.planx.uk/src/@planx/components/List/Public/index.tsx index 654e00a0ee..c1af007c9d 100644 --- a/editor.planx.uk/src/@planx/components/List/Public/index.tsx +++ b/editor.planx.uk/src/@planx/components/List/Public/index.tsx @@ -1,14 +1,22 @@ +import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import { styled } from "@mui/material/styles"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableRow from "@mui/material/TableRow"; import Typography from "@mui/material/Typography"; import { PublicProps } from "@planx/components/ui"; import React from "react"; +import { FONT_WEIGHT_SEMI_BOLD } from "theme"; import InputRow from "ui/shared/InputRow"; import Card from "../../shared/Preview/Card"; import CardHeader from "../../shared/Preview/CardHeader"; import type { Field, List } from "../model"; +import { ListProvider, useListContext } from "./Context"; import { NumberFieldInput, RadioFieldInput, @@ -28,6 +36,11 @@ const ListCard = styled(Box)(({ theme }) => ({ marginBottom: theme.spacing(2), })); +const CardButton = styled(Button)(({ theme }) => ({ + fontWeight: FONT_WEIGHT_SEMI_BOLD, + gap: theme.spacing(2), +})); + /** * Controller to return correct user input for field in schema */ @@ -47,23 +60,78 @@ const InputField: React.FC = (props) => { } }; -function ListComponent({ - info, - policyRef, - howMeasured, - schema, - handleSubmit, - title, - description, -}: Props) { - // TODO: Track user state, allow items to be added and removed - // TODO: Track "active" index - // TODO: Validate min / max - // TODO: Validate user input against schema fields, track errors - // TODO: On submit generate a payload +const ActiveListCard: React.FC<{ + index: number; +}> = ({ index }) => { + const { schema, saveItem, cancelEditItem } = useListContext(); + + return ( + // TODO: This should be a HTML form + + + {schema.type} {index + 1} + + {schema.fields.map((field, i) => ( + + + + ))} + + + + + + ); +}; + +const InactiveListCard: React.FC<{ + index: number; +}> = ({ index }) => { + const { schema, userData, removeItem, editItem } = useListContext(); + + return ( + + + {schema.type} {index + 1} + + + + {schema.fields.map((field, i) => ( + + + {field.data.title} + + {userData[index][i]?.val} + + ))} + +
+ + removeItem(index)}> + + Remove + + editItem(index)}> + {/* TODO: Is primary colour really right here? */} + + Edit + + +
+ ); +}; + +const Root = ({ title, description, info, policyRef, howMeasured }: Props) => { + const { userData, activeIndex, schema, addNewItem } = useListContext(); return ( - + console.log({ userData })} isValid> - - - {schema.type} index - - {schema.fields.map((field, i) => ( - - - - ))} - - - - - - ); +}; + +function ListComponent(props: Props) { + // TODO: Validate min / max + // TODO: Validate user input against schema fields, track errors + // TODO: On submit generate a payload + + return ( + + + + ); } export default ListComponent; diff --git a/editor.planx.uk/src/@planx/components/List/model.ts b/editor.planx.uk/src/@planx/components/List/model.ts index c0cef6f759..415411758b 100644 --- a/editor.planx.uk/src/@planx/components/List/model.ts +++ b/editor.planx.uk/src/@planx/components/List/model.ts @@ -10,20 +10,24 @@ import { SCHEMAS } from "./Editor"; interface QuestionInput { title: string; description?: string; - fn?: string; options: Option[]; } -export type TextField = { type: "text"; required?: boolean; data: TextInput }; +// TODO: Add summary fields for inactive view? +export type TextField = { + type: "text"; + required?: boolean; + data: TextInput & { fn: string }; +}; export type NumberField = { type: "number"; required?: boolean; - data: NumberInput; + data: NumberInput & { fn: string }; }; export type QuestionField = { type: "question"; required?: boolean; - data: QuestionInput; + data: QuestionInput & { fn: string }; }; /** @@ -38,7 +42,7 @@ export type Field = TextField | NumberField | QuestionField; export interface Schema { type: string; fields: Field[]; - min?: number; + min: number; max?: number; } @@ -58,3 +62,21 @@ export const parseContent = (data: Record | undefined): List => ({ schema: data?.schema || SCHEMAS[0].schema, ...parseMoreInformation(data), }); + +interface Response { + type: Field["type"]; + val: string; + fn: string; +} + +export type UserData = Response[][]; + +export const generateNewItem = (schema: Schema): Response[] => { + const item = schema.fields.map((field) => ({ + type: field.type, + val: "", + fn: field.data.fn, + })); + + return item; +};