Task 2e96585c: Definitions list and edit sheet (#198)

This commit was merged in pull request #198.
This commit is contained in:
2026-09-23 21:26:27 -04:00
parent dbe873eb2a
commit 33d8e0d2ac
9 changed files with 2385 additions and 1 deletions
@@ -0,0 +1,644 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import DefinitionsTab from "./DefinitionsTab";
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
import { storeTokens } from "../auth/oidc";
function child(id: number, name: string, overrides: Partial<AdminChild> = {}): AdminChild {
return {
id,
display_name: name,
colour: null,
avatar_ref: null,
sort_order: id,
is_active: true,
...overrides,
};
}
const CHILDREN: AdminChild[] = [
child(1, "Declan"),
child(2, "Maeve"),
child(3, "Rory"),
child(4, "Old", { is_active: false }),
];
function rule(overrides: Partial<DefinitionRule> = {}): DefinitionRule {
return {
rule_type: "daily",
interval: 1,
weekday_set: null,
day_of_month: null,
nth_weekday: null,
nth_weekday_weekday: null,
month: null,
start_date: "2026-01-01",
end_date: null,
...overrides,
};
}
const BRUSH: QuestDefinition = {
id: 10,
title: "Brush teeth",
description: null,
icon: "smile",
is_active: true,
skip_on_away: true,
rule: rule(),
assignees: [
{ id: 1, display_name: "Declan" },
{ id: 2, display_name: "Maeve" },
{ id: 3, display_name: "Rory" },
],
windows: [
{ window: "morning", due_time: "07:30" },
{ window: "evening", due_time: null },
],
};
const BINS: QuestDefinition = {
id: 11,
title: "Take out bins",
description: "Green bin",
icon: null,
is_active: true,
skip_on_away: false,
rule: rule({ rule_type: "weekly", interval: 2, weekday_set: [0, 3] }),
assignees: [
{ id: 1, display_name: "Declan" },
{ id: 4, display_name: "Old" },
],
windows: [{ window: "afternoon", due_time: "16:00" }],
};
const PIANO: QuestDefinition = {
id: 12,
title: "Piano practice",
description: null,
icon: null,
is_active: false,
skip_on_away: true,
rule: rule({ rule_type: "monthly_day", day_of_month: 15 }),
assignees: [{ id: 2, display_name: "Maeve" }],
windows: [{ window: "afternoon", due_time: null }],
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
interface Call {
method: string;
path: string;
body: unknown;
}
/**
* A stub of the admin API: serves the list and children, records writes, and
* lets a test queue a response for the next write.
*/
function fakeApi(initial: QuestDefinition[]) {
let definitions = [...initial];
const calls: Call[] = [];
let nextWrite: Response | null = null;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
const path = new URL(String(input), "http://localhost").pathname;
const method = init.method ?? "GET";
const body = typeof init.body === "string" ? JSON.parse(init.body) : null;
calls.push({ method, path, body });
if (method === "GET" && path === "/api/v1/admin/quest-definitions") {
return jsonResponse({ definitions });
}
if (method === "GET" && path === "/api/v1/admin/children") {
return jsonResponse({ children: CHILDREN });
}
if (nextWrite) {
const response = nextWrite;
nextWrite = null;
return response;
}
if (method === "POST" && path === "/api/v1/admin/quest-definitions") {
const created: QuestDefinition = {
id: 99,
title: body.title,
description: null,
icon: null,
is_active: true,
skip_on_away: body.skip_on_away,
rule: body.rule,
assignees: CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
id: c.id,
display_name: c.display_name,
})),
windows: body.windows.map(([window, due_time]: [string, string | null]) => ({
window,
due_time,
})),
};
definitions = [...definitions, created];
return jsonResponse(created, 201);
}
const match = /^\/api\/v1\/admin\/quest-definitions\/(\d+)$/.exec(path);
if (method === "PATCH" && match) {
const id = Number(match[1]);
definitions = definitions.map((d) =>
d.id === id
? {
...d,
title: body.title ?? d.title,
skip_on_away: body.skip_on_away ?? d.skip_on_away,
rule: body.rule ?? d.rule,
// Like the API: a sent list replaces the whole set; an absent one keeps it.
assignees:
"assignee_child_ids" in body
? CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
id: c.id,
display_name: c.display_name,
}))
: d.assignees,
}
: d,
);
return jsonResponse(definitions.find((d) => d.id === id));
}
return jsonResponse({ detail: "unexpected" }, 500);
});
return {
fetchMock,
calls,
writes: () => calls.filter((c) => c.method !== "GET"),
listFetches: () =>
calls.filter((c) => c.method === "GET" && c.path === "/api/v1/admin/quest-definitions")
.length,
failNextWrite: (response: Response) => {
nextWrite = response;
},
};
}
let api: ReturnType<typeof fakeApi>;
async function renderReady(initial: QuestDefinition[] = [BRUSH, BINS, PIANO]) {
api = fakeApi(initial);
vi.stubGlobal("fetch", api.fetchMock);
render(<DefinitionsTab />);
await screen.findByText("Brush teeth");
}
function meta(row: HTMLElement): string {
return (row.querySelector(".defs-row-meta") as HTMLElement).textContent ?? "";
}
function sheet(): HTMLElement {
return screen.getByRole("dialog");
}
describe("DefinitionsTab", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("header counts definitions and active children", async () => {
await renderReady();
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
expect(screen.getByText("3 definitions · 3 children")).toBeTruthy();
const [url, init] = api.fetchMock.mock.calls[0];
expect(String(url)).toMatch(/\/api\/v1\/admin\/quest-definitions$/);
expect((init as RequestInit & { headers: Record<string, string> }).headers.Authorization).toBe(
"Bearer at-123",
);
});
it("rows show `<assignees> · <recurrence> · <windows>` meta", async () => {
await renderReady();
const brush = screen.getByTestId("definition-10");
expect(within(brush).getByText("Brush teeth")).toBeTruthy();
expect(meta(brush)).toBe("All three · Daily · Morning, Evening");
expect(meta(screen.getByTestId("definition-11"))).toBe(
"Declan, Old · Every 2 weeks on Mon, Thu · Afternoon",
);
expect(brush.className).not.toContain("defs-row--inactive");
});
it("an inactive definition is styled inactive with `· inactive`", async () => {
await renderReady();
const piano = screen.getByTestId("definition-12");
expect(meta(piano)).toBe("Maeve · Monthly on day 15 · Afternoon · inactive");
expect(piano.className).toContain("defs-row--inactive");
});
it("filter chips filter by child", async () => {
await renderReady();
const chips = screen.getByRole("group", { name: "Filter by child" });
expect(
within(chips)
.getAllByRole("button")
.map((b) => b.textContent),
).toEqual(["All", "Declan", "Maeve", "Rory"]);
expect(within(chips).getByRole("button", { name: "All" }).className).toContain(
"defs-chip--active",
);
fireEvent.click(within(chips).getByRole("button", { name: "Maeve" }));
expect(within(chips).getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe(
"true",
);
expect(screen.queryByTestId("definition-10")).toBeTruthy();
expect(screen.queryByTestId("definition-11")).toBeNull();
expect(screen.queryByTestId("definition-12")).toBeTruthy();
fireEvent.click(within(chips).getByRole("button", { name: "Rory" }));
expect(screen.queryByTestId("definition-10")).toBeTruthy();
expect(screen.queryByTestId("definition-12")).toBeNull();
fireEvent.click(within(chips).getByRole("button", { name: "All" }));
expect(screen.getAllByRole("listitem")).toHaveLength(3);
});
it("New opens the sheet and saving POSTs the exact body, then re-fetches", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
await renderReady();
expect(api.listFetches()).toBe(1);
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
expect(within(s).getByRole("heading", { name: "New task" })).toBeTruthy();
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: " Feed the cat " },
});
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
expect(within(s).getByRole("button", { name: /Assigned children/ }).textContent).toContain(
"Declan, Rory",
);
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
target: { value: "weekly" },
});
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "2" },
});
// Today (Wednesday) is preselected; add Saturday.
expect(within(s).getByRole("button", { name: "Wednesday" }).getAttribute("aria-pressed")).toBe(
"true",
);
fireEvent.click(within(s).getByRole("button", { name: "Saturday" }));
fireEvent.click(within(s).getByRole("button", { name: "Evening" }));
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
fireEvent.change(within(s).getByLabelText("Morning due time"), {
target: { value: "07:15" },
});
const toggle = within(s).getByRole("switch", { name: "Skip on away days" });
expect(toggle.getAttribute("aria-checked")).toBe("true");
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-checked")).toBe("false");
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toEqual([
{
method: "POST",
path: "/api/v1/admin/quest-definitions",
body: {
title: "Feed the cat",
rule: {
rule_type: "weekly",
interval: 2,
weekday_set: [2, 5],
day_of_month: null,
nth_weekday: null,
nth_weekday_weekday: null,
month: null,
start_date: "2026-09-23",
end_date: null,
},
assignee_child_ids: [1, 3],
windows: [
["morning", "07:15"],
["evening", null],
],
skip_on_away: false,
},
},
]);
const created = await screen.findByTestId("definition-99");
expect(meta(created)).toBe("Declan, Rory · Every 2 weeks on Wed, Sat · Morning, Evening");
expect(api.listFetches()).toBe(2);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.getByText("4 definitions · 3 children")).toBeTruthy();
});
it("tapping a row opens the sheet in editing state and saving PATCHes", async () => {
await renderReady();
const row = screen.getByTestId("definition-11");
fireEvent.click(row);
// §3.1 editing state on the row.
expect(row.className).toContain("defs-row--editing");
expect(meta(row)).toBe("Editing");
expect(row.getAttribute("aria-expanded")).toBe("true");
const s = sheet();
expect(within(s).getByRole("heading", { name: "Edit task" })).toBeTruthy();
expect((within(s).getByRole("textbox", { name: "Title" }) as HTMLInputElement).value).toBe(
"Take out bins",
);
expect((within(s).getByRole("combobox", { name: "Repeats" }) as HTMLSelectElement).value).toBe(
"weekly",
);
expect((within(s).getByLabelText("Afternoon due time") as HTMLInputElement).value).toBe(
"16:00",
);
// The inactive child "Old" is shown locked, not offered as a checkbox.
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
expect(within(s).queryByRole("checkbox", { name: "Old" })).toBeNull();
const locked = within(s).getByTestId("inactive-assignee-4");
expect(locked.textContent).toContain("Old");
expect(locked.textContent).toContain("Inactive");
expect(locked.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" }));
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Take out the bins" },
});
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
target: { value: "monthly" },
});
fireEvent.change(within(s).getByRole("spinbutton", { name: "Day of month" }), {
target: { value: "3" },
});
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
target: { value: "1" },
});
fireEvent.change(within(s).getByLabelText("Afternoon due time"), {
target: { value: "" },
});
fireEvent.click(within(s).getByRole("switch", { name: "Skip on away days" }));
// Changing the selection would drop "Old": confirm before sending.
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
expect(api.writes()).toHaveLength(0);
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" }));
});
expect(api.writes()).toEqual([
{
method: "PATCH",
path: "/api/v1/admin/quest-definitions/11",
body: {
title: "Take out the bins",
rule: {
rule_type: "monthly_day",
interval: 1,
weekday_set: null,
day_of_month: 3,
nth_weekday: null,
nth_weekday_weekday: null,
month: null,
start_date: "2026-01-01",
end_date: null,
},
assignee_child_ids: [1, 2],
windows: [["afternoon", null]],
skip_on_away: true,
},
},
]);
// The icon is never sent, so it is carried through unchanged.
expect(api.writes()[0].body).not.toHaveProperty("icon");
await screen.findByText("Take out the bins");
expect(api.listFetches()).toBe(2);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("editing only the title omits assignee_child_ids and keeps an inactive assignee", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
expect(within(s).getByText(/Also assigned \(inactive\): Old\./)).toBeTruthy();
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Bins out" },
});
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
expect(api.writes()).toHaveLength(1);
const body = api.writes()[0].body as Record<string, unknown>;
expect(api.writes()[0].method).toBe("PATCH");
expect(body.title).toBe("Bins out");
expect(body).not.toHaveProperty("assignee_child_ids");
await screen.findByText("Bins out");
expect(meta(screen.getByTestId("definition-11"))).toBe(
"Declan, Old · Every 2 weeks on Mon, Thu · Afternoon",
);
});
it("changing the active selection with an inactive assignee asks first", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
const confirm = within(s).getByTestId("unassign-confirm");
expect(confirm.textContent).toContain("Saving will unassign Old.");
expect(document.activeElement).toBe(confirm);
expect(api.writes()).toHaveLength(0);
// Going back sends nothing and restores the normal footer.
fireEvent.click(within(confirm).getByRole("button", { name: "Go back" }));
expect(within(s).queryByTestId("unassign-confirm")).toBeNull();
expect(api.writes()).toHaveLength(0);
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" }));
});
expect(api.writes()).toHaveLength(1);
expect((api.writes()[0].body as { assignee_child_ids: number[] }).assignee_child_ids).toEqual([
1, 3,
]);
});
it("reverting the selection to the original omits assignee_child_ids without asking", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].body).not.toHaveProperty("assignee_child_ids");
});
it("focus moves into the sheet on open and returns to the row on Escape", async () => {
await renderReady();
const row = screen.getByTestId("definition-10");
row.focus();
fireEvent.click(row);
const s = sheet();
expect(document.activeElement).toBe(within(s).getByRole("heading", { name: "Edit task" }));
fireEvent.keyDown(s, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).toBe(row);
expect(api.writes()).toHaveLength(0);
});
it("Tab and Shift+Tab wrap within the sheet", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
const title = within(s).getByRole("textbox", { name: "Title" });
const save = within(s).getByRole("button", { name: "Save changes" });
save.focus();
fireEvent.keyDown(s, { key: "Tab" });
expect(document.activeElement).toBe(title);
fireEvent.keyDown(s, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(save);
});
it("Escape does not close the sheet while saving", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
let release: (response: Response) => void = () => {};
api.fetchMock.mockImplementationOnce(
() => new Promise<Response>((resolve) => (release = resolve)),
);
const s = sheet();
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
fireEvent.keyDown(s, { key: "Escape" });
expect(screen.getByRole("dialog")).toBeTruthy();
await act(async () => {
release(jsonResponse(BRUSH));
});
await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("the skip-on-away toggle reflects and persists a false value", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
const toggle = within(s).getByRole("switch", { name: "Skip on away days" });
expect(toggle.getAttribute("aria-checked")).toBe("false");
expect(toggle.className).not.toContain("defs-switch--on");
expect(within(s).getByText("No instance when the child is absent")).toBeTruthy();
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(1);
expect((api.writes()[0].body as { skip_on_away: boolean }).skip_on_away).toBe(false);
});
it("a 422 from save shows the API's detail and keeps the sheet open", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
api.failNextWrite(jsonResponse({ detail: "interval must be >= 1, got 0" }, 422));
await act(async () => {
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
});
expect(screen.getByTestId("sheet-error").textContent).toBe("interval must be >= 1, got 0");
expect(screen.getByRole("dialog")).toBeTruthy();
expect(api.listFetches()).toBe(1);
// Save is usable again after the failure.
expect(
(within(sheet()).getByRole("button", { name: "Save changes" }) as HTMLButtonElement).disabled,
).toBe(false);
});
it("a 403 from save shows the admin-group refusal", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
api.failNextWrite(jsonResponse({ detail: "Forbidden" }, 403));
await act(async () => {
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
});
expect(screen.getByTestId("sheet-error").textContent).toBe(
"Your account is not in the nestquest-admins group.",
);
});
it("guards against a double submit", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
const save = within(sheet()).getByRole("button", { name: "Save changes" });
await act(async () => {
fireEvent.click(save);
fireEvent.click(save);
});
expect(api.writes()).toHaveLength(1);
});
it("an incomplete new task is not submitted", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
expect(screen.getByTestId("sheet-error").textContent).toBe("Give the task a title.");
expect(api.writes()).toHaveLength(0);
});
it("Cancel closes the sheet without writing", async () => {
await renderReady();
fireEvent.click(screen.getByTestId("definition-10"));
fireEvent.click(within(sheet()).getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(meta(screen.getByTestId("definition-10"))).toBe("All three · Daily · Morning, Evening");
expect(api.writes()).toHaveLength(0);
});
it("a 403 on load renders the refusal", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ detail: "Forbidden" }, 403)),
);
render(<DefinitionsTab />);
expect((await screen.findByTestId("definitions-error")).textContent).toContain(
"Your account is not in the nestquest-admins group.",
);
});
it("scroll column carries the 92px bottom padding", async () => {
await renderReady();
expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px");
});
});