Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: List component - basic model, types & UI #3197

Merged
merged 5 commits into from
May 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 82 additions & 3 deletions editor.planx.uk/src/@planx/components/List/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import MenuItem from "@mui/material/MenuItem";
import { ComponentType as TYPES } from "@opensystemslab/planx-core/types";
import { useFormik } from "formik";
import React from "react";
import { FeaturePlaceholder } from "ui/editor/FeaturePlaceholder";
import ModalSection from "ui/editor/ModalSection";
import ModalSectionContent from "ui/editor/ModalSectionContent";
import RichTextInput from "ui/editor/RichTextInput";
import SelectInput from "ui/editor/SelectInput";
import Input from "ui/shared/Input";
import InputRow from "ui/shared/InputRow";
import InputRowItem from "ui/shared/InputRowItem";
import InputRowLabel from "ui/shared/InputRowLabel";

import { EditorProps } from "../ui";
import { EditorProps, ICONS, InternalNotes, MoreInformation } from "../ui";
import { List, parseContent } from "./model";
import { Zoo } from "./schemas/Zoo";

type Props = EditorProps<TYPES.List, List>;

export const SCHEMAS = [
{ name: "Zoo", schema: Zoo },
// TODO: Residential units
// TODO: Residential units (GLA)
];

function ListComponent(props: Props) {
const formik = useFormik({
initialValues: parseContent(props.node?.data),
Expand All @@ -21,7 +36,71 @@ function ListComponent(props: Props) {

return (
<form onSubmit={formik.handleSubmit} id="modal">
<FeaturePlaceholder title="Under development" />
<ModalSection>
<ModalSectionContent title="List" Icon={ICONS[TYPES.List]}>
<InputRow>
<Input
format="large"
name="title"
value={formik.values.title}
placeholder="Title"
onChange={formik.handleChange}
/>
</InputRow>
<InputRow>
<RichTextInput
placeholder="Description"
name="description"
value={formik.values.description}
onChange={formik.handleChange}
/>
</InputRow>
<InputRow>
<Input
format="data"
name="fn"
value={formik.values.fn}
placeholder="Data Field"
onChange={formik.handleChange}
/>
</InputRow>
<InputRow>
<InputRowLabel>Schema</InputRowLabel>
<InputRowItem>
<SelectInput
value={formik.values.schemaName}
onChange={(e) => {
formik.setFieldValue("schemaName", e.target.value);
formik.setFieldValue(
"schema",
SCHEMAS.find(
({ name }) => name === (e.target.value as string),
)?.schema,
);
}}
>
{SCHEMAS.map(({ name }) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
))}
</SelectInput>
</InputRowItem>
</InputRow>
</ModalSectionContent>
</ModalSection>
<MoreInformation
changeField={formik.handleChange}
definitionImg={formik.values.definitionImg}
howMeasured={formik.values.howMeasured}
policyRef={formik.values.policyRef}
info={formik.values.info}
/>
<InternalNotes
name="notes"
value={formik.values.notes}
onChange={formik.handleChange}
/>
</form>
);
}
Expand Down
10 changes: 0 additions & 10 deletions editor.planx.uk/src/@planx/components/List/Public.test.tsx

This file was deleted.

27 changes: 0 additions & 27 deletions editor.planx.uk/src/@planx/components/List/Public.tsx

This file was deleted.

137 changes: 137 additions & 0 deletions editor.planx.uk/src/@planx/components/List/Public/Fields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import Box from "@mui/material/Box";
import FormControl from "@mui/material/FormControl";
import FormLabel from "@mui/material/FormLabel";
import MenuItem from "@mui/material/MenuItem";
import RadioGroup from "@mui/material/RadioGroup";
import React from "react";
import SelectInput from "ui/editor/SelectInput";
import InputLabel from "ui/public/InputLabel";
import Input from "ui/shared/Input";
import InputRowLabel from "ui/shared/InputRowLabel";

import { DESCRIPTION_TEXT } from "../../shared/constants";
import BasicRadio from "../../shared/Radio/BasicRadio";
import type { NumberField, QuestionField, TextField } from "../model";

type Props<T> = T & { id: string };

export const TextFieldInput: React.FC<Props<TextField>> = ({
id,
data,
required,
}) => (
<InputLabel label={data.title} htmlFor={id}>
<Input
type={((type) => {
if (type === "email") return "email";
else if (type === "phone") return "tel";
return "text";
})(data.type)}
multiline={data.type && ["long", "extraLong"].includes(data.type)}
Comment on lines +25 to +30
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's some repeated code here from existing components.

I'm hoping that once error handling / form values are handled I can DRY this up a little with more generic, shared, input components.

bordered
id={id}
rows={
data.type && ["long", "extraLong"].includes(data.type) ? 5 : undefined
}
name="text"
required={required}
inputProps={{
"aria-describedby": [
data.description ? DESCRIPTION_TEXT : "",
// TODO: When handling errors, revisit this
// formik.errors.text ? `${ERROR_MESSAGE}-${inputFieldId}` : "",
]
.filter(Boolean)
.join(" "),
}}
/>
</InputLabel>
);

export const NumberFieldInput: React.FC<Props<NumberField>> = ({
id,
data,
required,
}) => (
<InputLabel label={data.title} htmlFor={id}>
<Box sx={{ display: "flex", alignItems: "baseline" }}>
<Input
required={required}
bordered
name="value"
type="number"
// value={formik.values.value}
// onChange={formik.handleChange}
// errorMessage={formik.errors.value as string}
inputProps={{
"aria-describedby": [
data.description ? DESCRIPTION_TEXT : "",
// formik.errors.value ? `${ERROR_MESSAGE}-${props.id}` : "",
]
.filter(Boolean)
.join(" "),
}}
id={id}
/>
{data.units && <InputRowLabel>{data.units}</InputRowLabel>}
</Box>
</InputLabel>
);

export const RadioFieldInput: React.FC<Props<QuestionField>> = ({
id,
data,
}) => (
<FormControl sx={{ width: "100%" }} component="fieldset">
<FormLabel
component="legend"
id={`radio-buttons-group-label-${id}`}
sx={(theme) => ({
color: theme.palette.text.primary,
"&.Mui-focused": {
color: theme.palette.text.primary,
},
})}
>
{data.title}
</FormLabel>
{/* <ErrorWrapper id={props.id} error={formik.errors.selected?.id}> */}
<RadioGroup
aria-labelledby={`radio-buttons-group-label-${id}`}
name={`radio-buttons-group-${id}`}
sx={{ p: 1 }}
// value={formik.values.selected.id}
>
{data.options.map(({ id, data }) => (
<BasicRadio
key={id}
id={id}
title={data.text}
onChange={() => console.log("change radio")}
/>
))}
</RadioGroup>
{/* </ErrorWrapper> */}
</FormControl>
);

export const SelectFieldInput: React.FC<Props<QuestionField>> = ({
id,
data,
required,
}) => (
<InputLabel label={data.title} id={`select-label-${id}`}>
<SelectInput
bordered
required={required}
title={data.title}
labelId={`select-label-${id}`}
>
{data.options.map((option) => (
<MenuItem key={option.id} value={option.data.text}>
{option.data.text}
</MenuItem>
))}
</SelectInput>
</InputLabel>
);
116 changes: 116 additions & 0 deletions editor.planx.uk/src/@planx/components/List/Public/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React from "react";
import { axe, setup } from "testUtils";

import ListComponent, { Props } from "../Public";
import { Zoo } from "../schemas/Zoo";

const mockProps: Props = {
fn: "mock",
schema: Zoo,
schemaName: "Zoo",
title: "Mock Title",
description: "Mock description",
};

describe("Basic UI", () => {
it("renders correctly", () => {
const { getByText } = setup(<ListComponent {...mockProps} />);

expect(getByText(/Mock Title/)).toBeInTheDocument();
expect(getByText(/Mock description/)).toBeInTheDocument();
});

it("parses provided schema to render expected form", async () => {
const { getByLabelText, getByText, user, getByRole, queryAllByRole } =
setup(<ListComponent {...mockProps} />);

// Text inputs are generated from schema...
const textInput = getByLabelText(/What's their name?/) as HTMLInputElement;
expect(textInput).toBeInTheDocument();
expect(textInput.type).toBe("text");

// Props are correctly read
const emailInput = getByLabelText(
/What's their email address?/,
) as HTMLInputElement;
expect(emailInput).toBeInTheDocument();
expect(emailInput.type).toBe("email");

// Number inputs are generated from schema
const numberInput = getByLabelText(/How old are they?/) as HTMLInputElement;
expect(numberInput).toBeInTheDocument();
expect(numberInput.type).toBe("number");

// Props are correctly read
const units = getByText(/years old/);
expect(units).toBeInTheDocument();

// Question inputs generated from schema
// Combobox displayed when number of options > 2
const selectInput = getByRole("combobox");
expect(selectInput).toBeInTheDocument();

// Open combobox
await user.click(selectInput);

// All options display
expect(getByRole("option", { name: "Small" })).toBeInTheDocument();
expect(getByRole("option", { name: "Medium" })).toBeInTheDocument();
expect(getByRole("option", { name: "Large" })).toBeInTheDocument();

// No default option selected
expect(
queryAllByRole("option", { selected: true }) as HTMLOptionElement[],
).toHaveLength(0);

// Close combobox
await user.click(selectInput);

// Radio groups displayed when number of options = 2
const radioInput = getByLabelText(/How cute are they?/);
expect(radioInput).toBeInTheDocument();

// All options display
const radioButton1 = getByLabelText("Very") as HTMLInputElement;
expect(radioButton1).toBeInTheDocument();
expect(radioButton1.type).toBe("radio");

const radioButton2 = getByLabelText("Super") as HTMLInputElement;
expect(radioButton2).toBeInTheDocument();
expect(radioButton2.type).toBe("radio");

// No default option selected
expect(radioButton1).not.toBeChecked();
expect(radioButton2).not.toBeChecked();

expect(getByText(/Save/, { selector: "button" })).toBeInTheDocument();
expect(getByText(/Cancel/, { selector: "button" })).toBeInTheDocument();
});

it("should not have any accessibility violations", async () => {
const { container } = setup(<ListComponent {...mockProps} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});

describe("Navigating back", () => {
test.todo("it pre-populates list correctly");
});

describe("Building a list", () => {
test.todo("Adding an item");
test.todo("Editing an item");
test.todo("Removing an item");
});

describe("Form validation and error handling", () => {
test.todo("Text field");
test.todo("Number field");
test.todo("Question field - select");
test.todo("Question field - radio");
});

describe("Payload generation", () => {
it.todo("generates a valid payload on submission");
});
Loading
Loading