Task b50b048f: Lucide icon picker (shipped subset) (#205)

This commit was merged in pull request #205.
This commit is contained in:
2026-09-23 22:39:25 -04:00
parent 4b56e2f68a
commit a22f990bd6
6 changed files with 348 additions and 10 deletions
+10
View File
@@ -8,6 +8,7 @@
"name": "nestquest-admin", "name": "nestquest-admin",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"lucide-react": "^1.47.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
}, },
@@ -2257,6 +2258,15 @@
"yallist": "^3.0.2" "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": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+2 -1
View File
@@ -10,6 +10,7 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"lucide-react": "^1.47.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
}, },
@@ -24,4 +25,4 @@
"vite": "^7.1.5", "vite": "^7.1.5",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
} }
+24
View File
@@ -414,6 +414,30 @@
gap: 6px; 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 { .defs-due-times {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+171 -4
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import DefinitionsTab from "./DefinitionsTab"; import DefinitionsTab from "./DefinitionsTab";
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions"; import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
import { storeTokens } from "../auth/oidc"; import { storeTokens } from "../auth/oidc";
@@ -134,7 +135,7 @@ function fakeApi(initial: QuestDefinition[]) {
id: 99, id: 99,
title: body.title, title: body.title,
description: null, description: null,
icon: null, icon: body.icon ?? null,
is_active: true, is_active: true,
skip_on_away: body.skip_on_away, skip_on_away: body.skip_on_away,
rule: body.rule, rule: body.rule,
@@ -158,6 +159,7 @@ function fakeApi(initial: QuestDefinition[]) {
? { ? {
...d, ...d,
title: body.title ?? d.title, title: body.title ?? d.title,
icon: "icon" in body ? body.icon : d.icon,
skip_on_away: body.skip_on_away ?? d.skip_on_away, skip_on_away: body.skip_on_away ?? d.skip_on_away,
rule: body.rule ?? d.rule, rule: body.rule ?? d.rule,
// Like the API: a sent list replaces the whole set; an absent one keeps it. // 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", path: "/api/v1/admin/quest-definitions",
body: { body: {
title: "Feed the cat", title: "Feed the cat",
icon: null,
rule: { rule: {
rule_type: "weekly", rule_type: "weekly",
interval: 2, interval: 2,
@@ -419,6 +422,7 @@ describe("DefinitionsTab", () => {
path: "/api/v1/admin/quest-definitions/11", path: "/api/v1/admin/quest-definitions/11",
body: { body: {
title: "Take out the bins", title: "Take out the bins",
icon: null,
rule: { rule: {
rule_type: "monthly_day", rule_type: "monthly_day",
interval: 1, 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"); await screen.findByText("Take out the bins");
expect(api.listFetches()).toBe(2); expect(api.listFetches()).toBe(2);
expect(screen.queryByRole("dialog")).toBeNull(); expect(screen.queryByRole("dialog")).toBeNull();
@@ -918,3 +919,169 @@ describe("DefinitionsTab occurrence preview", () => {
expect(api.previews()).toHaveLength(1); 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(<DefinitionIcon name={name} />);
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();
});
});
+38 -5
View File
@@ -1,8 +1,6 @@
/** /**
* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2). * Definitions ("Tasks") tab — list + edit sheet with icon picker
* * (design/ADMIN-SPEC.md §3.1–3.3).
* The icon picker (§3.3) is a separate task: an existing definition's icon is
* carried through unchanged by never sending it.
*/ */
import { import {
useEffect, useEffect,
@@ -44,6 +42,7 @@ import {
PlusGlyph, PlusGlyph,
TaskGlyph, TaskGlyph,
} from "./glyphs"; } from "./glyphs";
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
import "./DefinitionsTab.css"; import "./DefinitionsTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ /** 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 { interface Draft {
title: string; title: string;
/** A Lucide name; a legacy value outside the subset is kept until replaced. */
icon: string | null;
assigneeIds: number[]; assigneeIds: number[];
repeats: Repeats; repeats: Repeats;
interval: string; interval: string;
@@ -117,6 +118,7 @@ function newDraft(activeChildren: AdminChild[]): Draft {
const today = new Date(); const today = new Date();
return { return {
title: "", title: "",
icon: null,
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [], assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
repeats: "daily", repeats: "daily",
interval: "1", 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 ?? ""; for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
return { return {
title: definition.title, title: definition.title,
icon: definition.icon,
// Inactive children cannot be submitted (the API rejects them with 422). // Inactive children cannot be submitted (the API rejects them with 422).
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)), assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
repeats: REPEATS_BY_RULE[rule.rule_type], repeats: REPEATS_BY_RULE[rule.rule_type],
@@ -233,6 +236,7 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
); );
return { return {
title, title,
icon: draft.icon,
rule, rule,
assignee_child_ids: [...draft.assigneeIds], assignee_child_ids: [...draft.assigneeIds],
windows, windows,
@@ -319,7 +323,7 @@ function DefinitionRow({
onClick={onOpen} onClick={onOpen}
> >
<span className="defs-row-glyph"> <span className="defs-row-glyph">
<TaskGlyph /> <DefinitionIcon name={definition.icon} />
</span> </span>
<span className="defs-row-text"> <span className="defs-row-text">
<span className="defs-row-title">{definition.title}</span> <span className="defs-row-title">{definition.title}</span>
@@ -558,6 +562,35 @@ function EditSheet({
/> />
</Field> </Field>
<Field label="Icon" id="defs-label-icon">
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon">
<button
type="button"
role="radio"
aria-checked={draft.icon === null}
aria-label="No icon"
className={draft.icon === null ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: null })}
>
<TaskGlyph size={18} />
</button>
{DEFINITION_ICONS.map(({ name, label, Icon }) => (
<button
key={name}
type="button"
role="radio"
aria-checked={draft.icon === name}
aria-label={label}
data-icon-option={name}
className={draft.icon === name ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: name })}
>
<Icon size={18} aria-hidden="true" focusable="false" />
</button>
))}
</div>
</Field>
<Field label="Assigned children" id="defs-label-children"> <Field label="Assigned children" id="defs-label-children">
<button <button
type="button" type="button"
+103
View File
@@ -0,0 +1,103 @@
/**
* The shipped Lucide subset a definition's `icon` may name (ADMIN-SPEC §3.3).
* Only these named imports are bundled — no CDN, no runtime fetch. The stored
* value is the Lucide kebab-case name, so it stays meaningful to any surface.
*/
import {
Apple,
Backpack,
Bath,
Bed,
Bike,
BookOpen,
Brush,
BrushCleaning,
Calendar,
Cat,
Clock,
CookingPot,
Dog,
Droplets,
Fish,
Gamepad2,
Heart,
House,
ListChecks,
Moon,
Music,
Pencil,
Recycle,
Shirt,
ShowerHead,
Smile,
Sofa,
SprayCan,
Sprout,
Star,
Sun,
Toilet,
Trash2,
Utensils,
Volleyball,
WashingMachine,
type LucideIcon,
} from "lucide-react";
import { TaskGlyph } from "./glyphs";
export interface DefinitionIconEntry {
name: string;
label: string;
Icon: LucideIcon;
}
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = [
{ name: "bed", label: "Bed", Icon: Bed },
{ name: "bath", label: "Bath", Icon: Bath },
{ name: "shower-head", label: "Shower", Icon: ShowerHead },
{ name: "toilet", label: "Toilet", Icon: Toilet },
{ name: "brush", label: "Brush", Icon: Brush },
{ name: "smile", label: "Smile", Icon: Smile },
{ name: "shirt", label: "Clothes", Icon: Shirt },
{ name: "washing-machine", label: "Laundry", Icon: WashingMachine },
{ name: "utensils", label: "Meal", Icon: Utensils },
{ name: "cooking-pot", label: "Cooking", Icon: CookingPot },
{ name: "apple", label: "Snack", Icon: Apple },
{ name: "brush-cleaning", label: "Sweep", Icon: BrushCleaning },
{ name: "spray-can", label: "Clean", Icon: SprayCan },
{ name: "trash-2", label: "Bins", Icon: Trash2 },
{ name: "recycle", label: "Recycling", Icon: Recycle },
{ name: "sofa", label: "Tidy", Icon: Sofa },
{ name: "house", label: "Home", Icon: House },
{ name: "backpack", label: "School bag", Icon: Backpack },
{ name: "book-open", label: "Reading", Icon: BookOpen },
{ name: "pencil", label: "Homework", Icon: Pencil },
{ name: "music", label: "Music", Icon: Music },
{ name: "dog", label: "Dog", Icon: Dog },
{ name: "cat", label: "Cat", Icon: Cat },
{ name: "fish", label: "Fish", Icon: Fish },
{ name: "sprout", label: "Plants", Icon: Sprout },
{ name: "droplets", label: "Water", Icon: Droplets },
{ name: "bike", label: "Bike", Icon: Bike },
{ name: "volleyball", label: "Sport", Icon: Volleyball },
{ name: "gamepad-2", label: "Games", Icon: Gamepad2 },
{ name: "star", label: "Star", Icon: Star },
{ name: "heart", label: "Heart", Icon: Heart },
{ name: "sun", label: "Morning", Icon: Sun },
{ name: "moon", label: "Bedtime", Icon: Moon },
{ name: "clock", label: "Clock", Icon: Clock },
{ name: "calendar", label: "Calendar", Icon: Calendar },
{ name: "list-checks", label: "Checklist", Icon: ListChecks },
];
const BY_NAME = new Map(DEFINITION_ICONS.map((entry) => [entry.name, entry]));
export function findDefinitionIcon(name: string | null): DefinitionIconEntry | null {
return name === null ? null : (BY_NAME.get(name) ?? null);
}
/** A definition's glyph; no icon, or one outside the subset, gets the generic task glyph. */
export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) {
const entry = findDefinitionIcon(name);
if (!entry) return <TaskGlyph size={size} />;
return <entry.Icon size={size} aria-hidden="true" focusable="false" data-icon={entry.name} />;
}