Skip to content

Commit 577cf84

Browse files
authored
Add RepeatingEntry for better Rhode Island form UX (#563)
1 parent 3871fcb commit 577cf84

12 files changed

Lines changed: 326 additions & 24 deletions

File tree

src/components/common/TextField/TextField.css

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
.namesake-text-field {
2-
width: 100%;
32
color: var(--text-color);
43

54
.react-aria-Input,
@@ -11,7 +10,7 @@
1110
color: var(--field-text-color);
1211
background: var(--field-background);
1312
border: 1px solid var(--border-color);
14-
border-radius: var(--radius-xs);
13+
border-radius: var(--radius-s);
1514
box-shadow: inset 0 3px 0 0
1615
color-mix(in srgb, var(--namesake-black) 10%, transparent);
1716

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
.repeating-entry {
2+
display: flex;
3+
flex-direction: column;
4+
gap: var(--space-l);
5+
width: 100%;
6+
}
7+
8+
.repeating-entry-item {
9+
display: flex;
10+
flex-direction: row;
11+
gap: var(--space-s);
12+
min-width: 0;
13+
}
14+
15+
.repeating-entry-remove {
16+
flex-shrink: 0;
17+
align-self: flex-end;
18+
justify-self: flex-start;
19+
}
20+
21+
.repeating-entry-add {
22+
align-self: flex-start;
23+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import userEvent from "@testing-library/user-event";
2+
import { describe, expect, it } from "vitest";
3+
import { renderWithFormProvider, screen } from "../test-utils";
4+
import { RepeatingEntry } from "./RepeatingEntry";
5+
6+
function renderEntry(
7+
props?: Partial<React.ComponentProps<typeof RepeatingEntry>>,
8+
) {
9+
return renderWithFormProvider(
10+
<RepeatingEntry name="previousAddresses" {...props}>
11+
{(value, onChange, index) => (
12+
<input
13+
aria-label={`Address ${index + 1}`}
14+
value={value}
15+
onChange={(e) => onChange(e.target.value)}
16+
/>
17+
)}
18+
</RepeatingEntry>,
19+
);
20+
}
21+
22+
describe("RepeatingEntry", () => {
23+
describe("initial render", () => {
24+
it("renders one entry by default", () => {
25+
renderEntry();
26+
expect(screen.getAllByRole("textbox")).toHaveLength(1);
27+
});
28+
29+
it("renders min entries when no defaultCount is set", () => {
30+
renderEntry({ min: 2 });
31+
expect(screen.getAllByRole("textbox")).toHaveLength(2);
32+
});
33+
34+
it("renders no entries when min is 0 and no defaultCount is set", () => {
35+
renderEntry({ min: 0 });
36+
expect(screen.queryAllByRole("textbox")).toHaveLength(0);
37+
});
38+
39+
it("renders defaultCount entries regardless of min", () => {
40+
renderEntry({ min: 0, defaultCount: 1 });
41+
expect(screen.getAllByRole("textbox")).toHaveLength(1);
42+
});
43+
44+
it("renders defaultCount entries when defaultCount exceeds min", () => {
45+
renderEntry({ min: 1, defaultCount: 3, max: 3 });
46+
expect(screen.getAllByRole("textbox")).toHaveLength(3);
47+
});
48+
});
49+
50+
describe("Add button", () => {
51+
it("shows Add button when below max", () => {
52+
renderEntry({ max: 3 });
53+
expect(screen.getByRole("button", { name: /add/i })).toBeInTheDocument();
54+
});
55+
56+
it("hides Add button when at max", () => {
57+
renderEntry({ min: 3, max: 3 });
58+
expect(
59+
screen.queryByRole("button", { name: /add/i }),
60+
).not.toBeInTheDocument();
61+
});
62+
63+
it("adds an entry when clicked", async () => {
64+
renderEntry({ max: 3 });
65+
await userEvent.click(screen.getByRole("button", { name: /add/i }));
66+
expect(screen.getAllByRole("textbox")).toHaveLength(2);
67+
});
68+
69+
it("hides after reaching max", async () => {
70+
renderEntry({ min: 1, max: 2 });
71+
await userEvent.click(screen.getByRole("button", { name: /add/i }));
72+
expect(
73+
screen.queryByRole("button", { name: /add/i }),
74+
).not.toBeInTheDocument();
75+
});
76+
});
77+
78+
describe("Remove button", () => {
79+
it("hides Remove button when at min", () => {
80+
renderEntry({ min: 1 });
81+
expect(
82+
screen.queryByRole("button", { name: /remove/i }),
83+
).not.toBeInTheDocument();
84+
});
85+
86+
it("shows Remove button on last entry when above min", async () => {
87+
renderEntry({ min: 1, max: 3 });
88+
await userEvent.click(screen.getByRole("button", { name: /add/i }));
89+
expect(
90+
screen.getByRole("button", { name: /remove/i }),
91+
).toBeInTheDocument();
92+
});
93+
94+
it("removes the last entry when clicked", async () => {
95+
renderEntry({ min: 1, max: 3 });
96+
await userEvent.click(screen.getByRole("button", { name: /add/i }));
97+
expect(screen.getAllByRole("textbox")).toHaveLength(2);
98+
await userEvent.click(screen.getByRole("button", { name: /remove/i }));
99+
expect(screen.getAllByRole("textbox")).toHaveLength(1);
100+
});
101+
102+
it("cannot remove below min", async () => {
103+
renderEntry({ min: 2, max: 3 });
104+
await userEvent.click(screen.getByRole("button", { name: /add/i }));
105+
await userEvent.click(screen.getByRole("button", { name: /remove/i }));
106+
expect(screen.getAllByRole("textbox")).toHaveLength(2);
107+
expect(
108+
screen.queryByRole("button", { name: /remove/i }),
109+
).not.toBeInTheDocument();
110+
});
111+
112+
it("can remove all entries when min is 0", async () => {
113+
renderEntry({ min: 0, defaultCount: 1 });
114+
await userEvent.click(screen.getByRole("button", { name: /remove/i }));
115+
expect(screen.queryAllByRole("textbox")).toHaveLength(0);
116+
expect(screen.getByRole("button", { name: /add/i })).toBeInTheDocument();
117+
});
118+
});
119+
120+
describe("children", () => {
121+
it("passes value and index to children", () => {
122+
renderEntry({ min: 2 });
123+
expect(screen.getByLabelText("Address 1")).toBeInTheDocument();
124+
expect(screen.getByLabelText("Address 2")).toBeInTheDocument();
125+
});
126+
127+
it("calls onChange with updated value", async () => {
128+
renderEntry();
129+
await userEvent.type(screen.getByLabelText("Address 1"), "123 Main St");
130+
expect(screen.getByLabelText("Address 1")).toHaveValue("123 Main St");
131+
});
132+
133+
it("preserves values in other entries when one changes", async () => {
134+
renderEntry({ min: 1, max: 3 });
135+
await userEvent.type(screen.getByLabelText("Address 1"), "First");
136+
await userEvent.click(screen.getByRole("button", { name: /add/i }));
137+
await userEvent.type(screen.getByLabelText("Address 2"), "Second");
138+
expect(screen.getByLabelText("Address 1")).toHaveValue("First");
139+
expect(screen.getByLabelText("Address 2")).toHaveValue("Second");
140+
});
141+
});
142+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
2+
import { useRef, useState } from "react";
3+
import { useFormContext } from "react-hook-form";
4+
import type { FieldName, FormData } from "../../../constants/fields";
5+
import { Button } from "../../common/Button";
6+
import "./RepeatingEntry.css";
7+
8+
type ArrayFieldName = {
9+
[K in FieldName]: FormData[K] extends string[] ? K : never;
10+
}[FieldName];
11+
12+
export interface RepeatingEntryProps {
13+
/** The array field name to store all entries. */
14+
name: ArrayFieldName;
15+
16+
/**
17+
* Minimum number of visible instances.
18+
* @default 1
19+
*/
20+
min?: number;
21+
22+
/** Maximum number of visible instances. */
23+
max?: number;
24+
25+
/**
26+
* Number of slots shown on first render when the field has no saved data.
27+
* @default min.
28+
*/
29+
defaultCount?: number;
30+
31+
/** Render function called for each visible instance. */
32+
children: (
33+
value: string,
34+
onChange: (val: string) => void,
35+
index: number,
36+
) => React.ReactNode;
37+
}
38+
39+
export function RepeatingEntry({
40+
name,
41+
min = 1,
42+
max,
43+
defaultCount,
44+
children,
45+
}: RepeatingEntryProps) {
46+
const { watch, setValue } = useFormContext<FormData>();
47+
const raw = watch(name) ?? [];
48+
49+
const [count, setCount] = useState(() =>
50+
Math.max(raw.length, defaultCount ?? min),
51+
);
52+
53+
// Pad raw data with empty strings up to count so every slot has a value.
54+
const values = Array.from({ length: count }, (_, i) => raw[i] ?? "");
55+
56+
const keys = useRef<string[]>([]);
57+
while (keys.current.length < count) {
58+
keys.current.push(crypto.randomUUID());
59+
}
60+
61+
const handleChange = (index: number, val: string) => {
62+
// Extend raw if the changed index is beyond its current length.
63+
const next = Array.from(
64+
{ length: Math.max(raw.length, index + 1) },
65+
(_, i) => raw[i] ?? "",
66+
);
67+
next[index] = val;
68+
setValue(name, next);
69+
};
70+
71+
const handleAdd = () => {
72+
setCount((c) => (max === undefined ? c + 1 : Math.min(c + 1, max)));
73+
};
74+
75+
const handleRemove = () => {
76+
keys.current = keys.current.slice(0, -1);
77+
setValue(name, raw.slice(0, count - 1));
78+
setCount((c) => Math.max(c - 1, min));
79+
};
80+
81+
const canAdd = max === undefined || count < max;
82+
const canRemove = count > min;
83+
84+
return (
85+
<div className="repeating-entry">
86+
{values.map((value, index) => (
87+
<div key={keys.current[index]} className="repeating-entry-item">
88+
{children(value, (val) => handleChange(index, val), index)}
89+
{canRemove && index === count - 1 && (
90+
<Button
91+
type="button"
92+
variant="secondary"
93+
icon={RiDeleteBinLine}
94+
className="repeating-entry-remove"
95+
onPress={handleRemove}
96+
aria-label="Remove"
97+
/>
98+
)}
99+
</div>
100+
))}
101+
{canAdd && (
102+
<Button
103+
type="button"
104+
variant="secondary"
105+
icon={RiAddLine}
106+
className="repeating-entry-add"
107+
onPress={handleAdd}
108+
>
109+
Add
110+
</Button>
111+
)}
112+
</div>
113+
);
114+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type { RepeatingEntryProps } from "./RepeatingEntry";
2+
export { RepeatingEntry } from "./RepeatingEntry";

src/constants/fields.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,9 +269,11 @@ export const FIELD_DEFS = [
269269
{ name: "mothersMaidenName", label: "Mother's maiden name", type: "string" },
270270
{ name: "occupation", label: "Occupation", type: "string" },
271271
{ name: "maritalStatus", label: "Marital status", type: "string" },
272-
{ name: "previousAddress1", label: "Previous address 1", type: "string" },
273-
{ name: "previousAddress2", label: "Previous address 2", type: "string" },
274-
{ name: "previousAddress3", label: "Previous address 3", type: "string" },
272+
{
273+
name: "previousAddresses",
274+
label: "Previous addresses",
275+
type: "string[]",
276+
},
275277
{
276278
name: "shouldChangeBirthCertificate",
277279
label: "Update birth certificate?",

src/forms/court-order-ri/e2e.spec.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,13 @@ test("Rhode Island Court Order", async ({ page }, testInfo) => {
106106
await test.step("Previous addresses", async () => {
107107
await expect(
108108
page.getByRole("heading", {
109-
name: "What are your three most recent addresses?",
109+
name: "What are your most recent addresses?",
110110
}),
111111
).toBeVisible();
112112
await page
113113
.getByRole("textbox", { name: "Address 1" })
114114
.fill("100 Main St, Providence, RI 02903");
115+
await page.getByRole("button", { name: "Add" }).click();
115116
await page
116117
.getByRole("textbox", { name: "Address 2" })
117118
.fill("45 Oak Ave, Cranston, RI 02910");
@@ -229,6 +230,11 @@ test("Rhode Island Court Order", async ({ page }, testInfo) => {
229230
).toBeVisible();
230231
await expect(page.getByText("Residence street address: 100")).toBeVisible();
231232
await expect(page.getByText("Residence city: Providence")).toBeVisible();
233+
await expect(
234+
page.getByText(
235+
"Previous addresses: 100 Main St, Providence, RI 02903, 45 Oak Ave, Cranston, RI 02910",
236+
),
237+
).toBeVisible();
232238
await expect(page.getByText("Mother’s first name: Mary")).toBeVisible();
233239
await expect(page.getByText("Mother’s last name: Maiden")).toBeVisible();
234240
await expect(page.getByText("Father’s first name: John")).toBeVisible();
Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
1+
import { TextField } from "../../../components/common/TextField";
12
import { FormStep } from "../../../components/forms/FormStep";
2-
import { ShortTextField } from "../../../components/forms/ShortTextField";
3+
import { RepeatingEntry } from "../../../components/forms/RepeatingEntry";
34
import type { Step } from "../../../forms/types";
45

56
export const previousAddressesStep: Step = {
67
id: "previous-addresses",
7-
title: "What are your three most recent addresses?",
8-
description: "List where you have resided prior to your current address.",
9-
fields: ["previousAddress1", "previousAddress2", "previousAddress3"],
8+
title: "What are your most recent addresses?",
9+
description:
10+
"List up to three addresses where you have resided prior to your current address.",
11+
fields: [
12+
{
13+
id: "previousAddresses",
14+
when: (data) => data.previousAddresses?.some(Boolean) ?? false,
15+
},
16+
],
1017
component: ({ stepConfig }) => (
1118
<FormStep stepConfig={stepConfig}>
12-
<ShortTextField name="previousAddress1" label="Address 1" size={40} />
13-
<ShortTextField name="previousAddress2" label="Address 2" size={40} />
14-
<ShortTextField name="previousAddress3" label="Address 3" size={40} />
19+
<RepeatingEntry name="previousAddresses" min={0} max={3} defaultCount={1}>
20+
{(value, onChange, index) => (
21+
<TextField
22+
value={value}
23+
onChange={onChange}
24+
label={`Address ${index + 1}`}
25+
size={40}
26+
/>
27+
)}
28+
</RepeatingEntry>
1529
</FormStep>
1630
),
1731
};

src/layouts/ProseLayout.astro

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,14 +203,11 @@ const headerDescription =
203203

204204
p:not(:where(.not-content *)),
205205
ul:not(:where(.not-content *)),
206-
ol:not(:where(.not-content *)),
207-
img:not(:where(.not-content *)) {
206+
ol:not(:where(.not-content *)) {
208207
margin-block: var(--space-m);
209208
}
210209

211-
img:not(:where(.not-content *)),
212-
ul:not(:where(.not-content *)),
213-
ol:not(:where(.not-content *)) {
210+
img:not(:where(.not-content *)) {
214211
margin-block: var(--space-xl);
215212
}
216213

0 commit comments

Comments
 (0)