diff --git a/admin/package-lock.json b/admin/package-lock.json index 1baf4ca..7da0990 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -8,6 +8,7 @@ "name": "nestquest-admin", "version": "0.0.0", "dependencies": { + "lucide-react": "^1.47.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -2257,6 +2258,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz", + "integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/admin/package.json b/admin/package.json index 9726bfe..cb86399 100644 --- a/admin/package.json +++ b/admin/package.json @@ -10,6 +10,7 @@ "test": "vitest run" }, "dependencies": { + "lucide-react": "^1.47.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -24,4 +25,4 @@ "vite": "^7.1.5", "vitest": "^3.2.4" } -} \ No newline at end of file +} diff --git a/admin/src/definitions/DefinitionsTab.css b/admin/src/definitions/DefinitionsTab.css index 80c6fe7..a857716 100644 --- a/admin/src/definitions/DefinitionsTab.css +++ b/admin/src/definitions/DefinitionsTab.css @@ -414,6 +414,30 @@ gap: 6px; } +.defs-icon-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--nq-a-tap-min), 1fr)); + gap: 6px; +} + +.defs-icon { + display: flex; + align-items: center; + justify-content: center; + min-height: var(--nq-a-tap-min); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + cursor: pointer; +} + +.defs-icon--on { + border-color: transparent; + background: var(--nq-brand-gradient); + color: #fff; +} + .defs-due-times { display: flex; flex-direction: column; diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx index 238f769..038429b 100644 --- a/admin/src/definitions/DefinitionsTab.test.tsx +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import DefinitionsTab from "./DefinitionsTab"; +import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons"; import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions"; import { storeTokens } from "../auth/oidc"; @@ -134,7 +135,7 @@ function fakeApi(initial: QuestDefinition[]) { id: 99, title: body.title, description: null, - icon: null, + icon: body.icon ?? null, is_active: true, skip_on_away: body.skip_on_away, rule: body.rule, @@ -158,6 +159,7 @@ function fakeApi(initial: QuestDefinition[]) { ? { ...d, title: body.title ?? d.title, + icon: "icon" in body ? body.icon : d.icon, 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. @@ -331,6 +333,7 @@ describe("DefinitionsTab", () => { path: "/api/v1/admin/quest-definitions", body: { title: "Feed the cat", + icon: null, rule: { rule_type: "weekly", interval: 2, @@ -419,6 +422,7 @@ describe("DefinitionsTab", () => { path: "/api/v1/admin/quest-definitions/11", body: { title: "Take out the bins", + icon: null, rule: { rule_type: "monthly_day", interval: 1, @@ -436,9 +440,6 @@ describe("DefinitionsTab", () => { }, }, ]); - // 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(); @@ -918,3 +919,169 @@ describe("DefinitionsTab occurrence preview", () => { expect(api.previews()).toHaveLength(1); }); }); + +describe("DefinitionsTab icon picker", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + /** The Lucide name a row renders, or null for the generic fallback glyph. */ + function rowIcon(row: HTMLElement): string | null { + const svg = row.querySelector(".defs-row-glyph svg"); + expect(svg).not.toBeNull(); + return svg!.getAttribute("data-icon"); + } + + function iconRadio(s: HTMLElement, label: string): HTMLElement { + return within(within(s).getByRole("radiogroup", { name: "Icon" })).getByRole("radio", { + name: label, + }); + } + + it("ships the curated subset, and every entry renders an SVG", () => { + expect(DEFINITION_ICONS.map((entry) => entry.name)).toEqual([ + "bed", + "bath", + "shower-head", + "toilet", + "brush", + "smile", + "shirt", + "washing-machine", + "utensils", + "cooking-pot", + "apple", + "brush-cleaning", + "spray-can", + "trash-2", + "recycle", + "sofa", + "house", + "backpack", + "book-open", + "pencil", + "music", + "dog", + "cat", + "fish", + "sprout", + "droplets", + "bike", + "volleyball", + "gamepad-2", + "star", + "heart", + "sun", + "moon", + "clock", + "calendar", + "list-checks", + ]); + expect(DEFINITION_ICONS.length).toBeGreaterThanOrEqual(24); + expect(DEFINITION_ICONS.length).toBeLessThanOrEqual(40); + for (const { name } of DEFINITION_ICONS) { + const { container, unmount } = render(); + const svg = container.querySelector("svg"); + expect(svg, name).not.toBeNull(); + expect(svg!.getAttribute("data-icon")).toBe(name); + expect(svg!.querySelectorAll("path, circle, rect, line, polyline, ellipse").length).toBeGreaterThan(0); + unmount(); + } + }); + + it("a chosen icon is POSTed for a new task and renders on its row", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "New" })); + const s = sheet(); + expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true"); + + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Make the bed" }, + }); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" })); + fireEvent.click(within(s).getByRole("button", { name: "Morning" })); + fireEvent.click(iconRadio(s, "Bed")); + expect(iconRadio(s, "Bed").getAttribute("aria-checked")).toBe("true"); + expect(iconRadio(s, "Bed").className).toContain("defs-icon--on"); + expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect(api.writes()).toHaveLength(1); + expect(api.writes()[0].method).toBe("POST"); + expect((api.writes()[0].body as { icon: string }).icon).toBe("bed"); + expect(rowIcon(await screen.findByTestId("definition-99"))).toBe("bed"); + }); + + it("a chosen icon is PATCHed for an existing task without breaking the assignee omit", async () => { + await renderReady(); + expect(rowIcon(screen.getByTestId("definition-11"))).toBeNull(); + fireEvent.click(screen.getByTestId("definition-11")); + const s = sheet(); + expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true"); + fireEvent.click(iconRadio(s, "Bins")); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect(screen.queryByTestId("unassign-confirm")).toBeNull(); + expect(api.writes()).toHaveLength(1); + const { method, path, body } = api.writes()[0]; + expect(method).toBe("PATCH"); + expect(path).toBe("/api/v1/admin/quest-definitions/11"); + expect((body as { icon: string }).icon).toBe("trash-2"); + // Inactive "Old" stays assigned: the unchanged selection is still omitted. + expect(body).not.toHaveProperty("assignee_child_ids"); + await waitFor(() => expect(rowIcon(screen.getByTestId("definition-11"))).toBe("trash-2")); + }); + + it("an unchanged icon is still sent on edit and shows as selected", async () => { + await renderReady(); + expect(rowIcon(screen.getByTestId("definition-10"))).toBe("smile"); + fireEvent.click(screen.getByTestId("definition-10")); + const s = sheet(); + expect(iconRadio(s, "Smile").getAttribute("aria-checked")).toBe("true"); + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Brush teeth well" }, + }); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect((api.writes()[0].body as { icon: string }).icon).toBe("smile"); + }); + + it("an unknown legacy icon falls back to the generic glyph and is kept on save", async () => { + const legacy: QuestDefinition = { ...BRUSH, id: 20, icon: "rocket-ship-9000" }; + await renderReady([legacy]); + const row = screen.getByTestId("definition-20"); + expect(rowIcon(row)).toBeNull(); + + fireEvent.click(row); + const s = sheet(); + const group = within(s).getByRole("radiogroup", { name: "Icon" }); + for (const radio of within(group).getAllByRole("radio")) { + expect(radio.getAttribute("aria-checked")).toBe("false"); + } + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + + expect((api.writes()[0].body as { icon: string }).icon).toBe("rocket-ship-9000"); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull(); + }); +}); diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx index 05cc19a..b4803b5 100644 --- a/admin/src/definitions/DefinitionsTab.tsx +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -1,8 +1,6 @@ /** - * Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2). - * - * The icon picker (§3.3) is a separate task: an existing definition's icon is - * carried through unchanged by never sending it. + * Definitions ("Tasks") tab — list + edit sheet with icon picker + * (design/ADMIN-SPEC.md §3.1–3.3). */ import { useEffect, @@ -44,6 +42,7 @@ import { PlusGlyph, TaskGlyph, } from "./glyphs"; +import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons"; import "./DefinitionsTab.css"; /** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ @@ -78,6 +77,8 @@ const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "S interface Draft { title: string; + /** A Lucide name; a legacy value outside the subset is kept until replaced. */ + icon: string | null; assigneeIds: number[]; repeats: Repeats; interval: string; @@ -117,6 +118,7 @@ function newDraft(activeChildren: AdminChild[]): Draft { const today = new Date(); return { title: "", + icon: null, assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [], repeats: "daily", interval: "1", @@ -141,6 +143,7 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? ""; return { title: definition.title, + icon: definition.icon, // Inactive children cannot be submitted (the API rejects them with 422). assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)), repeats: REPEATS_BY_RULE[rule.rule_type], @@ -233,6 +236,7 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody | ); return { title, + icon: draft.icon, rule, assignee_child_ids: [...draft.assigneeIds], windows, @@ -319,7 +323,7 @@ function DefinitionRow({ onClick={onOpen} > - + {definition.title} @@ -558,6 +562,35 @@ function EditSheet({ /> + +
+ + {DEFINITION_ICONS.map(({ name, label, Icon }) => ( + + ))} +
+
+