Feature 19 follow-up: Manage children screen (#222) #223
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Typed client for the admin child-profile write routes
|
||||
* (/api/v1/admin/children). The list read is `fetchChildren` in ./definitions.
|
||||
*/
|
||||
import { apiFetch } from "./client";
|
||||
import { CHILDREN_PATH, readJson, type AdminChild } from "./definitions";
|
||||
|
||||
export interface ChildCreateBody {
|
||||
display_name: string;
|
||||
colour?: string;
|
||||
avatar_ref?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Omitted fields stay unchanged. The API rejects an explicit null, so a set
|
||||
* colour or avatar cannot be cleared through this route.
|
||||
*/
|
||||
export type ChildEditBody = Partial<ChildCreateBody>;
|
||||
|
||||
export const CHILDREN_REORDER_PATH = `${CHILDREN_PATH}/reorder`;
|
||||
|
||||
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
|
||||
return apiFetch(path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createChild(body: ChildCreateBody): Promise<AdminChild> {
|
||||
return readJson<AdminChild>(await sendJson(CHILDREN_PATH, "POST", body));
|
||||
}
|
||||
|
||||
export async function updateChild(id: number, body: ChildEditBody): Promise<AdminChild> {
|
||||
return readJson<AdminChild>(await sendJson(`${CHILDREN_PATH}/${id}`, "PATCH", body));
|
||||
}
|
||||
|
||||
export async function setChildActive(id: number, isActive: boolean): Promise<AdminChild> {
|
||||
return readJson<AdminChild>(
|
||||
await sendJson(`${CHILDREN_PATH}/${id}/active`, "PATCH", { is_active: isActive }),
|
||||
);
|
||||
}
|
||||
|
||||
/** `orderedIds` must be every child id in the household, in the new order. */
|
||||
export async function reorderChildren(orderedIds: number[]): Promise<void> {
|
||||
await readJson<{ status: string }>(
|
||||
await sendJson(CHILDREN_REORDER_PATH, "POST", { ordered_ids: orderedIds }),
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ function errorMessage(error: unknown, fallback: string): string {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
export const FOCUSABLE =
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
@@ -53,7 +53,7 @@ const FOCUSABLE =
|
||||
* <body>), so the tab bar behind the pushed screen cannot take focus.
|
||||
* Returns the undo.
|
||||
*/
|
||||
function inertOutside(root: HTMLElement): () => void {
|
||||
export function inertOutside(root: HTMLElement): () => void {
|
||||
const made: HTMLElement[] = [];
|
||||
for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) {
|
||||
for (const sibling of Array.from(node.parentElement.children)) {
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
/* Settings — pushed screen over the Today tab (and the tab bar), like the
|
||||
presence override editor. Compact density list, roomy form card. */
|
||||
|
||||
.settings-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
overflow-y: auto;
|
||||
background: var(--nq-a-page);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
}
|
||||
|
||||
.settings-back {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--nq-a-tap-min);
|
||||
height: var(--nq-a-tap-min);
|
||||
margin: -6px 0 -6px -12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--nq-a-ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
margin: 0;
|
||||
font-size: var(--nq-a-size-screen);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.settings-sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
||||
.settings-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nq-a-density-gap);
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.settings-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-section-label {
|
||||
margin: 0;
|
||||
font-size: var(--nq-a-size-label);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--nq-a-track-label);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.settings-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.settings-list > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
.settings-child {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
}
|
||||
|
||||
.settings-child--inactive .settings-child-name {
|
||||
color: var(--nq-a-ink-tertiary);
|
||||
}
|
||||
|
||||
.settings-swatch {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
border: 1px solid rgba(9, 9, 11, 0.12);
|
||||
}
|
||||
|
||||
.settings-child-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-child-name {
|
||||
font-size: var(--nq-a-size-row);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-child-meta {
|
||||
margin-top: 1px;
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-tertiary);
|
||||
}
|
||||
|
||||
.settings-pill {
|
||||
flex: none;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-divider);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-pill--active {
|
||||
background: #dcfce7;
|
||||
color: var(--nq-a-success);
|
||||
}
|
||||
|
||||
.settings-child-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-icon-button,
|
||||
.settings-action {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-icon-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-action {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.settings-icon-button:disabled,
|
||||
.settings-action:disabled,
|
||||
.settings-add:disabled,
|
||||
.settings-back:disabled,
|
||||
.settings-cancel:disabled,
|
||||
.settings-save:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.settings-empty,
|
||||
.settings-status {
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-status--error {
|
||||
color: var(--nq-a-danger-strong);
|
||||
}
|
||||
|
||||
.settings-status p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.settings-action-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-danger-bg);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Add / edit form: roomy density, 18px padding, 16px gaps. */
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.settings-form-title {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.settings-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
font-size: var(--nq-a-size-label);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--nq-a-track-label);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 13px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: 10px;
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-swatches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-swatch-option {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-swatch-option[aria-pressed="true"] {
|
||||
border-color: var(--nq-a-ink);
|
||||
box-shadow: inset 0 0 0 2px var(--nq-a-surface);
|
||||
}
|
||||
|
||||
.settings-error {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-danger-bg);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-form-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-cancel,
|
||||
.settings-save {
|
||||
padding: 15px 12px;
|
||||
border-radius: 12px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-cancel {
|
||||
flex: 1;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
}
|
||||
|
||||
.settings-save {
|
||||
flex: 2;
|
||||
border: 0;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import App from "../App";
|
||||
import SettingsScreen, { SCROLL_BOTTOM_PADDING } from "./SettingsScreen";
|
||||
import type { AdminChild } from "../api/definitions";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
|
||||
const CHILDREN_PATH = "/api/v1/admin/children";
|
||||
const REORDER_PATH = `${CHILDREN_PATH}/reorder`;
|
||||
|
||||
function child(
|
||||
id: number,
|
||||
display_name: string,
|
||||
sort_order: number,
|
||||
extra: Partial<AdminChild> = {},
|
||||
): AdminChild {
|
||||
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true, ...extra };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let children: AdminChild[];
|
||||
/** Overrides the next write's response (the store is left untouched). */
|
||||
let writeResponse: (() => Response) | null;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function calls(): Call[] {
|
||||
return fetchMock.mock.calls.map((args: unknown[]) => {
|
||||
const [url, init] = args as [string, RequestInit | undefined];
|
||||
return {
|
||||
method: init?.method ?? "GET",
|
||||
path: new URL(String(url), "http://x").pathname,
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function writes(): Call[] {
|
||||
return calls().filter((c) => c.method !== "GET");
|
||||
}
|
||||
|
||||
function childGets(): number {
|
||||
return calls().filter((c) => c.method === "GET" && c.path === CHILDREN_PATH).length;
|
||||
}
|
||||
|
||||
async function routeFetch(url: string, init: RequestInit = {}): Promise<Response> {
|
||||
const path = new URL(url, "http://x").pathname;
|
||||
const method = init.method ?? "GET";
|
||||
const body = init.body ? JSON.parse(String(init.body)) : undefined;
|
||||
if (method !== "GET" && writeResponse) {
|
||||
const respond = writeResponse;
|
||||
writeResponse = null;
|
||||
return respond();
|
||||
}
|
||||
if (path === CHILDREN_PATH && method === "GET") return jsonResponse({ children });
|
||||
if (path === CHILDREN_PATH && method === "POST") {
|
||||
const created = child(9, body.display_name, body.sort_order ?? 0, {
|
||||
colour: body.colour ?? null,
|
||||
avatar_ref: body.avatar_ref ?? null,
|
||||
});
|
||||
children = [...children, created];
|
||||
return jsonResponse(created, 201);
|
||||
}
|
||||
if (path === REORDER_PATH && method === "POST") {
|
||||
const ids = body.ordered_ids as number[];
|
||||
children = children.map((c) => ({ ...c, sort_order: ids.indexOf(c.id) + 1 }));
|
||||
return jsonResponse({ status: "ok" });
|
||||
}
|
||||
const activeMatch = /\/children\/(\d+)\/active$/.exec(path);
|
||||
if (activeMatch && method === "PATCH") {
|
||||
const id = Number(activeMatch[1]);
|
||||
children = children.map((c) => (c.id === id ? { ...c, is_active: body.is_active } : c));
|
||||
return jsonResponse(children.find((c) => c.id === id));
|
||||
}
|
||||
const editMatch = /\/children\/(\d+)$/.exec(path);
|
||||
if (editMatch && method === "PATCH") {
|
||||
const id = Number(editMatch[1]);
|
||||
children = children.map((c) => (c.id === id ? { ...c, ...body } : c));
|
||||
return jsonResponse(children.find((c) => c.id === id));
|
||||
}
|
||||
if (path.endsWith("/admin/snapshot")) {
|
||||
return jsonResponse({ today_iso: "2026-09-23", cycle_day: 5, children: [] });
|
||||
}
|
||||
return jsonResponse({ detail: "unexpected" }, 500);
|
||||
}
|
||||
|
||||
function row(id: number) {
|
||||
return within(screen.getByTestId(`settings-child-${id}`));
|
||||
}
|
||||
|
||||
function listNames(): string[] {
|
||||
return within(screen.getByRole("list", { name: "Children" }))
|
||||
.getAllByRole("listitem")
|
||||
.map((li) => li.querySelector(".settings-child-name")?.textContent ?? "");
|
||||
}
|
||||
|
||||
function field(form: HTMLElement, label: string): HTMLInputElement {
|
||||
return within(form).getByLabelText(label) as HTMLInputElement;
|
||||
}
|
||||
|
||||
async function renderScreen(onClose = vi.fn()) {
|
||||
render(<SettingsScreen onClose={onClose} />);
|
||||
await screen.findByTestId("settings-child-1");
|
||||
return onClose;
|
||||
}
|
||||
|
||||
describe("SettingsScreen — children", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
children = [
|
||||
child(2, "Maeve", 2, { colour: "#16a34a" }),
|
||||
child(1, "Declan", 1, { colour: "#0ea5e9", avatar_ref: "fox" }),
|
||||
child(3, "Rory", 3, { is_active: false }),
|
||||
];
|
||||
writeResponse = null;
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("lists each child with name, colour swatch, active state and order", async () => {
|
||||
await renderScreen();
|
||||
expect(listNames()).toEqual(["Declan", "Maeve", "Rory"]);
|
||||
expect(row(1).getByText("Order 1 · #0ea5e9")).toBeTruthy();
|
||||
expect(screen.getByTestId("settings-swatch-1").style.background).toBe("rgb(14, 165, 233)");
|
||||
expect(screen.getByTestId("settings-active-1").textContent).toBe("Active");
|
||||
expect(row(3).getByText("Order 3 · no colour")).toBeTruthy();
|
||||
expect(screen.getByTestId("settings-swatch-3").style.background).toBe("var(--nq-a-border)");
|
||||
expect(screen.getByTestId("settings-active-3").textContent).toBe("Inactive");
|
||||
expect(screen.getByTestId("settings-scroll").style.paddingBottom).toBe(SCROLL_BOTTOM_PADDING);
|
||||
});
|
||||
|
||||
it("Add posts exactly the entered values, then shows the new child", async () => {
|
||||
await renderScreen();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add child" }));
|
||||
const form = screen.getByRole("form", { name: "Add child" });
|
||||
expect(field(form, "Order").value).toBe("4");
|
||||
fireEvent.change(field(form, "Display name"), { target: { value: " Nora " } });
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Colour #7c3aed" }));
|
||||
fireEvent.change(field(form, "Avatar ref (optional)"), { target: { value: "owl" } });
|
||||
fireEvent.change(field(form, "Order"), { target: { value: "5" } });
|
||||
const gets = childGets();
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
|
||||
|
||||
await screen.findByTestId("settings-child-9");
|
||||
expect(writes()).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
path: CHILDREN_PATH,
|
||||
body: { display_name: "Nora", colour: "#7c3aed", avatar_ref: "owl", sort_order: 5 },
|
||||
},
|
||||
]);
|
||||
expect(childGets()).toBe(gets + 1);
|
||||
expect(listNames()).toEqual(["Declan", "Maeve", "Rory", "Nora"]);
|
||||
expect(screen.queryByRole("form", { name: "Add child" })).toBeNull();
|
||||
});
|
||||
|
||||
it("Add omits optional fields left blank and rejects a missing name", async () => {
|
||||
await renderScreen();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add child" }));
|
||||
const form = screen.getByRole("form", { name: "Add child" });
|
||||
fireEvent.change(field(form, "Order"), { target: { value: "" } });
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
|
||||
expect(screen.getByTestId("child-form-error").textContent).toBe("Enter a display name.");
|
||||
expect(writes()).toEqual([]);
|
||||
|
||||
fireEvent.change(field(form, "Display name"), { target: { value: "Nora" } });
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
|
||||
await screen.findByTestId("settings-child-9");
|
||||
expect(writes()[0].body).toEqual({ display_name: "Nora" });
|
||||
});
|
||||
|
||||
it("Edit patches only the changed fields", async () => {
|
||||
await renderScreen();
|
||||
fireEvent.click(row(1).getByRole("button", { name: "Edit Declan" }));
|
||||
const form = screen.getByRole("form", { name: "Edit Declan" });
|
||||
expect(field(form, "Display name").value).toBe("Declan");
|
||||
expect(field(form, "Colour").value).toBe("#0ea5e9");
|
||||
fireEvent.change(field(form, "Display name"), { target: { value: "Dec" } });
|
||||
fireEvent.change(field(form, "Colour"), { target: { value: "#FF8800" } });
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save child" }));
|
||||
|
||||
await waitFor(() => expect(row(1).getByText("Dec")).toBeTruthy());
|
||||
expect(writes()).toEqual([
|
||||
{ method: "PATCH", path: `${CHILDREN_PATH}/1`, body: { display_name: "Dec", colour: "#FF8800" } },
|
||||
]);
|
||||
expect(row(1).getByText("Order 1 · #FF8800")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Edit refuses to clear a set colour instead of sending null", async () => {
|
||||
await renderScreen();
|
||||
fireEvent.click(row(1).getByRole("button", { name: "Edit Declan" }));
|
||||
const form = screen.getByRole("form", { name: "Edit Declan" });
|
||||
fireEvent.change(field(form, "Colour"), { target: { value: "" } });
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save child" }));
|
||||
expect(screen.getByTestId("child-form-error").textContent).toMatch(/cannot be removed/);
|
||||
expect(writes()).toEqual([]);
|
||||
});
|
||||
|
||||
it("the active toggle patches /children/{id}/active with the new value", async () => {
|
||||
await renderScreen();
|
||||
fireEvent.click(row(1).getByRole("button", { name: "Deactivate Declan" }));
|
||||
await waitFor(() => expect(screen.getByTestId("settings-active-1").textContent).toBe("Inactive"));
|
||||
fireEvent.click(row(3).getByRole("button", { name: "Reactivate Rory" }));
|
||||
await waitFor(() => expect(screen.getByTestId("settings-active-3").textContent).toBe("Active"));
|
||||
expect(writes()).toEqual([
|
||||
{ method: "PATCH", path: `${CHILDREN_PATH}/1/active`, body: { is_active: false } },
|
||||
{ method: "PATCH", path: `${CHILDREN_PATH}/3/active`, body: { is_active: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reorder posts the complete ordered id list, then shows the new order", async () => {
|
||||
await renderScreen();
|
||||
expect((row(1).getByRole("button", { name: "Move Declan up" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((row(3).getByRole("button", { name: "Move Rory down" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(row(3).getByRole("button", { name: "Move Rory up" }));
|
||||
await waitFor(() => expect(listNames()).toEqual(["Declan", "Rory", "Maeve"]));
|
||||
expect(writes()).toEqual([{ method: "POST", path: REORDER_PATH, body: { ordered_ids: [1, 3, 2] } }]);
|
||||
});
|
||||
|
||||
it("disables the write controls while a request is in flight (no double submit)", async () => {
|
||||
await renderScreen();
|
||||
let release!: () => void;
|
||||
writeResponse = () => jsonResponse({ status: "ok" });
|
||||
const gate = new Promise<void>((r) => {
|
||||
release = r;
|
||||
});
|
||||
fetchMock.mockImplementationOnce(async (url: string, init?: RequestInit) => {
|
||||
await gate;
|
||||
return routeFetch(url, init);
|
||||
});
|
||||
const up = row(2).getByRole("button", { name: "Move Maeve up" }) as HTMLButtonElement;
|
||||
fireEvent.click(up);
|
||||
fireEvent.click(up);
|
||||
await waitFor(() => expect(up.disabled).toBe(true));
|
||||
expect((row(1).getByRole("button", { name: "Deactivate Declan" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(writes()).toHaveLength(1);
|
||||
release();
|
||||
await waitFor(() => expect((row(1).getByRole("button", { name: "Edit Declan" }) as HTMLButtonElement).disabled).toBe(false));
|
||||
expect(writes()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a 422 shows the API detail and keeps the form and the screen open", async () => {
|
||||
const onClose = await renderScreen();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add child" }));
|
||||
const form = screen.getByRole("form", { name: "Add child" });
|
||||
fireEvent.change(field(form, "Display name"), { target: { value: "Nora" } });
|
||||
writeResponse = () => jsonResponse({ detail: "display_name is required" }, 422);
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
|
||||
|
||||
const error = await screen.findByTestId("child-form-error");
|
||||
expect(error.textContent).toBe("The child could not be added: display_name is required");
|
||||
expect(screen.getByRole("form", { name: "Add child" })).toBeTruthy();
|
||||
expect(field(form, "Display name").value).toBe("Nora");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("dialog", { name: "Settings" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a 403 on a row action shows the refusal message and keeps the screen open", async () => {
|
||||
const onClose = await renderScreen();
|
||||
writeResponse = () => jsonResponse({ detail: "forbidden" }, 403);
|
||||
fireEvent.click(row(1).getByRole("button", { name: "Deactivate Declan" }));
|
||||
const error = await screen.findByTestId("settings-action-error");
|
||||
expect(error.textContent).toContain("Your account is not in the nestquest-admins group.");
|
||||
expect(screen.getByTestId("settings-active-1").textContent).toBe("Active");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a 403 on load shows the refusal message", async () => {
|
||||
fetchMock.mockImplementation(async () => jsonResponse({ detail: "forbidden" }, 403));
|
||||
render(<SettingsScreen onClose={vi.fn()} />);
|
||||
const error = await screen.findByTestId("settings-error");
|
||||
expect(error.textContent).toContain("Your account is not in the nestquest-admins group.");
|
||||
});
|
||||
|
||||
it("Escape and Back close the screen", async () => {
|
||||
const onClose = await renderScreen();
|
||||
fireEvent.keyDown(screen.getByRole("dialog", { name: "Settings" }), { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("the Today Settings button opens the screen; closing restores focus and the background", async () => {
|
||||
render(<App />);
|
||||
const settings = await screen.findByRole("button", { name: "Settings" });
|
||||
settings.focus();
|
||||
fireEvent.click(settings);
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Settings" });
|
||||
await screen.findByTestId("settings-child-1");
|
||||
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Settings" }));
|
||||
const tabbar = document.querySelector("nav.admin-tabbar") as HTMLElement;
|
||||
expect(tabbar.hasAttribute("inert")).toBe(true);
|
||||
expect(dialog.closest("[inert]")).toBeNull();
|
||||
|
||||
fireEvent.keyDown(dialog, { key: "Escape" });
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Settings" })).toBeNull());
|
||||
expect(tabbar.hasAttribute("inert")).toBe(false);
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Settings" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* Settings — a pushed screen opened from the Today header's Settings button.
|
||||
*
|
||||
* Its one section so far is Children: list, add, edit, deactivate/reactivate
|
||||
* and reorder the household's child profiles. Every write refetches the list.
|
||||
*
|
||||
* TODO: the other household settings (horizon days, day rollover time,
|
||||
* notification config) are a separate later task.
|
||||
*/
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from "react";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import {
|
||||
createChild,
|
||||
reorderChildren,
|
||||
setChildActive,
|
||||
updateChild,
|
||||
type ChildCreateBody,
|
||||
type ChildEditBody,
|
||||
} from "../api/children";
|
||||
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
|
||||
import { FOCUSABLE, inertOutside } from "../schedule/OverrideEditor";
|
||||
import { ArrowDownGlyph, ArrowUpGlyph, ChevronLeftGlyph, PlusGlyph } from "./glyphs";
|
||||
import "./SettingsScreen.css";
|
||||
|
||||
/** The swatch picker's presets; any other "#RRGGBB" can be typed. */
|
||||
export const COLOUR_PRESETS = ["#e11d48", "#f97316", "#eab308", "#16a34a", "#0ea5e9", "#7c3aed"];
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
||||
|
||||
const HEX_COLOUR = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; children: AdminChild[] };
|
||||
|
||||
type FormTarget = null | "new" | AdminChild;
|
||||
|
||||
interface Draft {
|
||||
name: string;
|
||||
colour: string;
|
||||
avatar: string;
|
||||
order: string;
|
||||
}
|
||||
|
||||
/** Display order: sort_order, ties broken by id (the API's own order). */
|
||||
function sortChildren(children: AdminChild[]): AdminChild[] {
|
||||
return [...children].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, action: string): string {
|
||||
if (error instanceof ApiForbiddenError) return error.message;
|
||||
if (error instanceof ApiRequestError && error.detail) return `${action}: ${error.detail}`;
|
||||
return `${action}. Check your connection and try again.`;
|
||||
}
|
||||
|
||||
function draftFor(target: "new" | AdminChild, siblings: AdminChild[]): Draft {
|
||||
if (target === "new") {
|
||||
const next = siblings.reduce((max, c) => Math.max(max, c.sort_order), 0) + 1;
|
||||
return { name: "", colour: "", avatar: "", order: String(next) };
|
||||
}
|
||||
return {
|
||||
name: target.display_name,
|
||||
colour: target.colour ?? "",
|
||||
avatar: target.avatar_ref ?? "",
|
||||
order: String(target.sort_order),
|
||||
};
|
||||
}
|
||||
|
||||
/** The request body for the draft, or a validation message. */
|
||||
function buildBody(
|
||||
draft: Draft,
|
||||
target: "new" | AdminChild,
|
||||
): ChildCreateBody | ChildEditBody | string {
|
||||
const name = draft.name.trim();
|
||||
const colour = draft.colour.trim();
|
||||
const avatar = draft.avatar.trim();
|
||||
const order = draft.order.trim();
|
||||
if (!name) return "Enter a display name.";
|
||||
if (colour && !HEX_COLOUR.test(colour)) return "Colour must be a hex value like #0ea5e9.";
|
||||
if (order && !/^-?\d+$/.test(order)) return "Order must be a whole number.";
|
||||
|
||||
if (target === "new") {
|
||||
const body: ChildCreateBody = { display_name: name };
|
||||
if (colour) body.colour = colour;
|
||||
if (avatar) body.avatar_ref = avatar;
|
||||
if (order) body.sort_order = Number(order);
|
||||
return body;
|
||||
}
|
||||
|
||||
// The API rejects null, so a set colour, avatar or order cannot be cleared.
|
||||
if (!colour && target.colour !== null) return "A colour, once set, cannot be removed. Pick another.";
|
||||
if (!avatar && target.avatar_ref !== null) return "An avatar ref, once set, cannot be removed.";
|
||||
if (!order) return "Enter an order.";
|
||||
const body: ChildEditBody = {};
|
||||
if (name !== target.display_name) body.display_name = name;
|
||||
if (colour && colour !== target.colour) body.colour = colour;
|
||||
if (avatar && avatar !== target.avatar_ref) body.avatar_ref = avatar;
|
||||
if (Number(order) !== target.sort_order) body.sort_order = Number(order);
|
||||
if (Object.keys(body).length === 0) return "No changes to save.";
|
||||
return body;
|
||||
}
|
||||
|
||||
function ChildForm({
|
||||
target,
|
||||
siblings,
|
||||
saving,
|
||||
error,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
target: "new" | AdminChild;
|
||||
/** The current list; a new child's order defaults to after the last. */
|
||||
siblings: AdminChild[];
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (body: ChildCreateBody | ChildEditBody | string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Draft>(() => draftFor(target, siblings));
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
const update = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
const isNew = target === "new";
|
||||
const title = isNew ? "Add child" : `Edit ${target.display_name}`;
|
||||
|
||||
useEffect(() => {
|
||||
nameRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
onSubmit(buildBody(draft, target));
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="settings-card settings-form" aria-label={title} onSubmit={submit} noValidate>
|
||||
<h4 className="settings-form-title">{title}</h4>
|
||||
<label className="settings-field">
|
||||
<span className="settings-label">Display name</span>
|
||||
<input
|
||||
ref={nameRef}
|
||||
className="settings-input"
|
||||
type="text"
|
||||
required
|
||||
value={draft.name}
|
||||
disabled={saving}
|
||||
onChange={(event) => update({ name: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="settings-field">
|
||||
<label className="settings-field">
|
||||
<span className="settings-label">Colour</span>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="text"
|
||||
placeholder="#0ea5e9"
|
||||
value={draft.colour}
|
||||
disabled={saving}
|
||||
onChange={(event) => update({ colour: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="settings-swatches" role="group" aria-label="Colour presets">
|
||||
{COLOUR_PRESETS.map((hex) => (
|
||||
<button
|
||||
key={hex}
|
||||
type="button"
|
||||
className="settings-swatch-option"
|
||||
style={{ background: hex }}
|
||||
aria-label={`Colour ${hex}`}
|
||||
aria-pressed={draft.colour.toLowerCase() === hex}
|
||||
disabled={saving}
|
||||
onClick={() => update({ colour: hex })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="settings-field">
|
||||
<span className="settings-label">Avatar ref (optional)</span>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={draft.avatar}
|
||||
disabled={saving}
|
||||
onChange={(event) => update({ avatar: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
<span className="settings-label">Order</span>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={draft.order}
|
||||
disabled={saving}
|
||||
onChange={(event) => update({ order: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
<p className="settings-error" role="alert" data-testid="child-form-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="settings-form-footer">
|
||||
<button type="button" className="settings-cancel" disabled={saving} onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="settings-save" disabled={saving} aria-busy={saving}>
|
||||
{saving ? "Saving…" : isNew ? "Add child" : "Save child"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildRow({
|
||||
child,
|
||||
index,
|
||||
count,
|
||||
busy,
|
||||
onEdit,
|
||||
onMove,
|
||||
onToggleActive,
|
||||
}: {
|
||||
child: AdminChild;
|
||||
index: number;
|
||||
count: number;
|
||||
/** The in-flight action key, or null. Every write control waits on it. */
|
||||
busy: string | null;
|
||||
onEdit: () => void;
|
||||
onMove: (delta: -1 | 1) => void;
|
||||
onToggleActive: () => void;
|
||||
}) {
|
||||
const locked = busy !== null;
|
||||
const toggling = busy === `active-${child.id}`;
|
||||
return (
|
||||
<li
|
||||
className={child.is_active ? "settings-child" : "settings-child settings-child--inactive"}
|
||||
data-testid={`settings-child-${child.id}`}
|
||||
>
|
||||
<span
|
||||
className="settings-swatch"
|
||||
data-testid={`settings-swatch-${child.id}`}
|
||||
style={{ background: child.colour ?? "var(--nq-a-border)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="settings-child-text">
|
||||
<div className="settings-child-name">{child.display_name}</div>
|
||||
<div className="settings-child-meta">
|
||||
Order {child.sort_order} · {child.colour ?? "no colour"}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={child.is_active ? "settings-pill settings-pill--active" : "settings-pill"}
|
||||
data-testid={`settings-active-${child.id}`}
|
||||
>
|
||||
{child.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
<div className="settings-child-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-icon-button"
|
||||
aria-label={`Move ${child.display_name} up`}
|
||||
aria-busy={busy === `move-${child.id}`}
|
||||
disabled={locked || index === 0}
|
||||
onClick={() => onMove(-1)}
|
||||
>
|
||||
<ArrowUpGlyph />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-icon-button"
|
||||
aria-label={`Move ${child.display_name} down`}
|
||||
aria-busy={busy === `move-${child.id}`}
|
||||
disabled={locked || index === count - 1}
|
||||
onClick={() => onMove(1)}
|
||||
>
|
||||
<ArrowDownGlyph />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-action"
|
||||
aria-label={`Edit ${child.display_name}`}
|
||||
disabled={locked}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-action"
|
||||
aria-label={`${child.is_active ? "Deactivate" : "Reactivate"} ${child.display_name}`}
|
||||
aria-busy={toggling}
|
||||
disabled={locked}
|
||||
onClick={onToggleActive}
|
||||
>
|
||||
{toggling ? "Saving…" : child.is_active ? "Deactivate" : "Reactivate"}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [form, setForm] = useState<FormTarget>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
// A ref, not state: two taps in one frame must not both see `busy === null`.
|
||||
const inFlight = useRef(false);
|
||||
const screenRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const addRef = useRef<HTMLButtonElement>(null);
|
||||
const formWasOpen = useRef(false);
|
||||
|
||||
// Modal focus: move in on open, keep the background out of reach, and hand
|
||||
// focus back to the opener (the Settings button) on close.
|
||||
useEffect(() => {
|
||||
const trigger = document.activeElement as HTMLElement | null;
|
||||
titleRef.current?.focus();
|
||||
const restoreBackground = screenRef.current ? inertOutside(screenRef.current) : undefined;
|
||||
return () => {
|
||||
restoreBackground?.();
|
||||
if (trigger && trigger.isConnected) trigger.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The form's controls unmount when it closes; hand focus to Add child then
|
||||
// (after the render that re-enables it).
|
||||
useEffect(() => {
|
||||
if (formWasOpen.current && form === null) addRef.current?.focus();
|
||||
formWasOpen.current = form !== null;
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState((current) => (current.kind === "ready" ? current : { kind: "loading" }));
|
||||
fetchChildren()
|
||||
.then((children) => {
|
||||
if (!cancelled) setState({ kind: "ready", children: sortChildren(children) });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState({ kind: "error", message: errorMessage(error, "Children could not be loaded") });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (!inFlight.current) onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !screenRef.current) return;
|
||||
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const current = document.activeElement;
|
||||
const outside = !focusable.includes(current as HTMLElement);
|
||||
if (event.shiftKey && (current === first || outside)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (current === last || outside)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one write, then refetch the list. One write at a time; a failure
|
||||
* keeps the screen (and any open form) as it was and says why.
|
||||
*/
|
||||
async function run(key: string, write: () => Promise<unknown>, action: string): Promise<boolean> {
|
||||
if (inFlight.current) return false;
|
||||
inFlight.current = true;
|
||||
setBusy(key);
|
||||
setActionError(null);
|
||||
setFormError(null);
|
||||
try {
|
||||
await write();
|
||||
} catch (error) {
|
||||
const message = errorMessage(error, action);
|
||||
if (key === "form") setFormError(message);
|
||||
else setActionError(message);
|
||||
inFlight.current = false;
|
||||
setBusy(null);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const children = await fetchChildren();
|
||||
setState({ kind: "ready", children: sortChildren(children) });
|
||||
} catch (error) {
|
||||
setActionError(errorMessage(error, "Saved, but the children list could not be refreshed"));
|
||||
}
|
||||
inFlight.current = false;
|
||||
setBusy(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
setForm(null);
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
async function submitForm(body: ChildCreateBody | ChildEditBody | string) {
|
||||
if (form === null) return;
|
||||
if (typeof body === "string") {
|
||||
setFormError(body);
|
||||
return;
|
||||
}
|
||||
const saved =
|
||||
form === "new"
|
||||
? await run("form", () => createChild(body as ChildCreateBody), "The child could not be added")
|
||||
: await run("form", () => updateChild(form.id, body), "The child could not be saved");
|
||||
if (saved) closeForm();
|
||||
}
|
||||
|
||||
function move(children: AdminChild[], index: number, delta: -1 | 1) {
|
||||
const ids = children.map((c) => c.id);
|
||||
const target = index + delta;
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
void run(`move-${children[index].id}`, () => reorderChildren(ids), "The order could not be saved");
|
||||
}
|
||||
|
||||
function toggleActive(child: AdminChild) {
|
||||
void run(
|
||||
`active-${child.id}`,
|
||||
() => setChildActive(child.id, !child.is_active),
|
||||
child.is_active ? "The child could not be deactivated" : "The child could not be reactivated",
|
||||
);
|
||||
}
|
||||
|
||||
let body;
|
||||
if (state.kind === "loading") {
|
||||
body = (
|
||||
<div className="settings-status" role="status" data-testid="settings-loading">
|
||||
Loading children…
|
||||
</div>
|
||||
);
|
||||
} else if (state.kind === "error") {
|
||||
body = (
|
||||
<div className="settings-status settings-status--error" role="alert" data-testid="settings-error">
|
||||
<p>{state.message}</p>
|
||||
<button type="button" className="settings-action" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const { children } = state;
|
||||
body = (
|
||||
<>
|
||||
{children.length > 0 ? (
|
||||
<ul className="settings-card settings-list" aria-label="Children">
|
||||
{children.map((child, index) => (
|
||||
<ChildRow
|
||||
key={child.id}
|
||||
child={child}
|
||||
index={index}
|
||||
count={children.length}
|
||||
busy={busy}
|
||||
onEdit={() => {
|
||||
setFormError(null);
|
||||
setForm(child);
|
||||
}}
|
||||
onMove={(delta) => move(children, index, delta)}
|
||||
onToggleActive={() => toggleActive(child)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="settings-card settings-empty">No children yet.</div>
|
||||
)}
|
||||
{form !== null ? (
|
||||
<ChildForm
|
||||
key={form === "new" ? "new" : form.id}
|
||||
target={form}
|
||||
siblings={children}
|
||||
saving={busy === "form"}
|
||||
error={formError}
|
||||
onCancel={closeForm}
|
||||
onSubmit={(b) => void submitForm(b)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={screenRef}
|
||||
className="settings-screen"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="settings-title"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<header className="settings-header">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-back"
|
||||
aria-label="Back"
|
||||
disabled={busy !== null}
|
||||
onClick={onClose}
|
||||
>
|
||||
<ChevronLeftGlyph size={20} />
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="settings-title" id="settings-title" ref={titleRef} tabIndex={-1}>
|
||||
Settings
|
||||
</h2>
|
||||
<p className="settings-sub">Household setup</p>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
className="settings-scroll"
|
||||
data-testid="settings-scroll"
|
||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
||||
>
|
||||
<div className="settings-section-head">
|
||||
<h3 className="settings-section-label">Children</h3>
|
||||
<button
|
||||
ref={addRef}
|
||||
type="button"
|
||||
className="settings-add"
|
||||
disabled={state.kind !== "ready" || busy !== null || form === "new"}
|
||||
onClick={() => {
|
||||
setFormError(null);
|
||||
setForm("new");
|
||||
}}
|
||||
>
|
||||
<PlusGlyph />
|
||||
Add child
|
||||
</button>
|
||||
</div>
|
||||
{actionError !== null ? (
|
||||
<div className="settings-action-error" role="alert" data-testid="settings-action-error">
|
||||
<span>{actionError}</span>
|
||||
<button type="button" className="settings-action" onClick={() => setActionError(null)}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Inline stroke glyphs for the Settings screen (Lucide-style 24px grid),
|
||||
* CDN-free like the tabs' glyphs.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function Glyph({ size, children }: { size: number; children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronLeftGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="m5 12 7-7 7 7M12 19V5" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDownGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="M12 5v14M19 12l-7 7-7-7" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ApiForbiddenError } from "../api/client";
|
||||
import { ApiRequestError } from "../api/definitions";
|
||||
import { uncompleteInstance } from "../api/instances";
|
||||
import { useTransitions } from "../api/useTransitions";
|
||||
import SettingsScreen from "../settings/SettingsScreen";
|
||||
import {
|
||||
formatClockTime,
|
||||
formatDueTime,
|
||||
@@ -43,15 +44,22 @@ function isOverdueOpen(instance: SnapshotInstance): boolean {
|
||||
return instance.overdue && instance.state === "open";
|
||||
}
|
||||
|
||||
function ScreenHeader({ sub }: { sub: string }) {
|
||||
function ScreenHeader({ sub, onSettings }: { sub: string; onSettings: () => void }) {
|
||||
return (
|
||||
<header className="today-header">
|
||||
<div>
|
||||
<h2 className="today-title">Today</h2>
|
||||
<p className="today-sub">{sub}</p>
|
||||
</div>
|
||||
{/* Settings screen is a later task; the control is not wired yet. */}
|
||||
<button type="button" className="today-icon-button" aria-label="Settings">
|
||||
{/* Opens the Settings screen. Only its Children section exists so far.
|
||||
TODO: horizon days, day rollover time and notification config are a
|
||||
separate later task. */}
|
||||
<button
|
||||
type="button"
|
||||
className="today-icon-button"
|
||||
aria-label="Settings"
|
||||
onClick={onSettings}
|
||||
>
|
||||
<SettingsGlyph />
|
||||
</button>
|
||||
</header>
|
||||
@@ -221,6 +229,7 @@ export default function TodayTab() {
|
||||
const [undoingId, setUndoingId] = useState<number | null>(null);
|
||||
const undoingRef = useRef(false);
|
||||
const [undoError, setUndoError] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
function refresh() {
|
||||
silentNext.current = true;
|
||||
@@ -326,7 +335,7 @@ export default function TodayTab() {
|
||||
|
||||
return (
|
||||
<div className="today-screen">
|
||||
<ScreenHeader sub={sub} />
|
||||
<ScreenHeader sub={sub} onSettings={() => setSettingsOpen(true)} />
|
||||
<div
|
||||
className="today-scroll"
|
||||
data-testid="today-scroll"
|
||||
@@ -342,6 +351,15 @@ export default function TodayTab() {
|
||||
) : null}
|
||||
{body}
|
||||
</div>
|
||||
{settingsOpen ? (
|
||||
<SettingsScreen
|
||||
onClose={() => {
|
||||
setSettingsOpen(false);
|
||||
// Child names and order feed the snapshot.
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user