Task b50b048f: Lucide icon picker (shipped subset) #205
Generated
+10
@@ -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",
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<span className="defs-row-glyph">
|
||||
<TaskGlyph />
|
||||
<DefinitionIcon name={definition.icon} />
|
||||
</span>
|
||||
<span className="defs-row-text">
|
||||
<span className="defs-row-title">{definition.title}</span>
|
||||
@@ -558,6 +562,35 @@ function EditSheet({
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user