diff --git a/admin/src/definitions/DefinitionsTab.css b/admin/src/definitions/DefinitionsTab.css index a857716..bd47f8a 100644 --- a/admin/src/definitions/DefinitionsTab.css +++ b/admin/src/definitions/DefinitionsTab.css @@ -583,3 +583,14 @@ color: var(--nq-a-ink-secondary); font-style: italic; } + +.defs-sublabel { + margin-top: 4px; + font-size: 12px; + font-weight: 700; + color: var(--nq-a-ink-secondary); +} + +.defs-emoji { + line-height: 1; +} diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx index 7f5ef3e..2f6d818 100644 --- a/admin/src/definitions/DefinitionsTab.test.tsx +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -1,7 +1,15 @@ 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, normalizeDefinitionIconName } from "./definitionIcons"; +import { + DEFINITION_ICONS, + DefinitionIcon, + FA_DEFINITION_ICONS, + emojiIconKey, + faIconName, + findDefinitionIcon, + normalizeDefinitionIconName, +} from "./definitionIcons"; import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions"; import { storeTokens } from "../auth/oidc"; @@ -1166,7 +1174,7 @@ describe("DefinitionsTab namespaced icons", () => { }); } - for (const icon of ["fa:dog", "emoji:๐Ÿถ", "mystery:dog"]) { + for (const icon of ["mystery:dog", "lucide:not-an-icon", "fa:not-an-icon", "emoji:a b"]) { it(`renders the fallback tile and selects nothing for ${icon}`, async () => { await renderReady([{ ...BRUSH, id: 31, icon }]); const row = screen.getByTestId("definition-31"); @@ -1174,9 +1182,11 @@ describe("DefinitionsTab namespaced icons", () => { 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"); + for (const name of ["Icon", "Font Awesome"]) { + const group = within(s).getByRole("radiogroup", { name }); + for (const radio of within(group).getAllByRole("radio")) { + expect(radio.getAttribute("aria-checked")).toBe("false"); + } } }); } @@ -1207,6 +1217,231 @@ describe("DefinitionsTab namespaced icons", () => { }); }); +describe("DefinitionsTab Font Awesome and emoji icons", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + /** What a row draws: `svg:`, `emoji:`, or `fallback`. */ + function rowGlyph(row: HTMLElement): string { + const glyph = row.querySelector(".defs-row-glyph"); + const emoji = glyph!.querySelector("[data-emoji]"); + if (emoji) return `emoji:${emoji.textContent}`; + const svg = glyph!.querySelector("svg"); + expect(svg).not.toBeNull(); + const name = svg!.getAttribute("data-icon"); + if (name === null) return "fallback"; + return `${svg!.getAttribute("fill") === "none" ? "lucide" : "fa"}:${name}`; + } + + function radio(s: HTMLElement, group: "Icon" | "Font Awesome", label: string): HTMLElement { + return within(within(s).getByRole("radiogroup", { name: group })).getByRole("radio", { + name: label, + }); + } + + function emojiInput(s: HTMLElement): HTMLInputElement { + return within(s).getByRole("textbox", { name: "Emoji" }) as HTMLInputElement; + } + + async function save(s: HTMLElement) { + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + } + + function fillNewTask(s: HTMLElement) { + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Walk the dog" }, + }); + 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" })); + } + + it("ships the curated Font Awesome subset, each drawn as a filled glyph", () => { + expect(FA_DEFINITION_ICONS.length).toBeGreaterThan(0); + for (const entry of FA_DEFINITION_ICONS) { + expect(entry.kind).toBe("fa"); + expect(findDefinitionIcon(`fa:${entry.name}`)).toBe(entry); + const { container, unmount } = render(); + const svg = container.querySelector("svg"); + expect(svg, entry.name).not.toBeNull(); + expect(svg!.getAttribute("data-icon")).toBe(entry.name); + expect(svg!.getAttribute("fill")).toBe("currentColor"); + unmount(); + } + }); + + it("builds and validates emoji keys like the registry", () => { + expect(emojiIconKey("๐Ÿถ")).toBe("emoji:๐Ÿถ"); + expect(emojiIconKey("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ")).toBe("emoji:๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"); + expect(emojiIconKey("โญ".repeat(8))).toBe(`emoji:${"โญ".repeat(8)}`); + for (const bad of ["", " ", " ๐Ÿถ ", "๐Ÿถ ", "\t๐Ÿถ", "โญ".repeat(9), "a b", "", "a&b", "๐Ÿถ\u0007"]) { + expect(emojiIconKey(bad), JSON.stringify(bad)).toBeNull(); + } + expect(faIconName("fa:dog")).toBe("dog"); + expect(faIconName("lucide:dog")).toBeNull(); + expect(faIconName(null)).toBeNull(); + }); + + it("renders lucide, legacy, fa and emoji rows, and the fallback for none or unknown", async () => { + await renderReady([ + { ...BRUSH, id: 40, icon: "lucide:dog" }, + { ...BRUSH, id: 41, title: "Task 41", icon: "dog" }, + { ...BRUSH, id: 42, title: "Task 42", icon: "fa:dog" }, + { ...BRUSH, id: 43, title: "Task 43", icon: "emoji:๐Ÿถ" }, + { ...BRUSH, id: 44, title: "Task 44", icon: null }, + { ...BRUSH, id: 45, title: "Task 45", icon: "" }, + { ...BRUSH, id: 46, title: "Task 46", icon: "mystery:dog" }, + ]); + expect(rowGlyph(screen.getByTestId("definition-40"))).toBe("lucide:dog"); + expect(rowGlyph(screen.getByTestId("definition-41"))).toBe("lucide:dog"); + expect(rowGlyph(screen.getByTestId("definition-42"))).toBe("fa:dog"); + expect(rowGlyph(screen.getByTestId("definition-43"))).toBe("emoji:๐Ÿถ"); + expect(rowGlyph(screen.getByTestId("definition-44"))).toBe("fallback"); + expect(rowGlyph(screen.getByTestId("definition-45"))).toBe("fallback"); + expect(rowGlyph(screen.getByTestId("definition-46"))).toBe("fallback"); + }); + + it("a Font Awesome choice is POSTed as fa: and renders on its row", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "New" })); + const s = sheet(); + fillNewTask(s); + fireEvent.click(radio(s, "Font Awesome", "Dog")); + expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("true"); + expect(radio(s, "Font Awesome", "Dog").className).toContain("defs-icon--on"); + expect(radio(s, "Icon", "Dog").getAttribute("aria-checked")).toBe("false"); + expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false"); + + await save(s); + expect(api.writes()).toHaveLength(1); + expect(api.writes()[0].method).toBe("POST"); + expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:dog"); + expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("fa:dog"); + }); + + it("a typed emoji is POSTed as emoji: and renders on its row", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "New" })); + const s = sheet(); + fillNewTask(s); + fireEvent.change(emojiInput(s), { target: { value: "๐Ÿถ" } }); + expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false"); + + await save(s); + expect(api.writes()).toHaveLength(1); + expect((api.writes()[0].body as { icon: string }).icon).toBe("emoji:๐Ÿถ"); + expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("emoji:๐Ÿถ"); + }); + + it("a Lucide choice is POSTed as lucide: after an emoji was typed", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "New" })); + const s = sheet(); + fillNewTask(s); + fireEvent.change(emojiInput(s), { target: { value: "๐Ÿถ" } }); + fireEvent.click(radio(s, "Icon", "Dog")); + expect(emojiInput(s).value).toBe(""); + + await save(s); + expect((api.writes()[0].body as { icon: string }).icon).toBe("lucide:dog"); + expect(rowGlyph(await screen.findByTestId("definition-99"))).toBe("lucide:dog"); + }); + + for (const bad of [" ๐Ÿถ ", "๐Ÿถ ", " ", "a b", "", "a&b", "โญ".repeat(9)]) { + it(`an invalid emoji entry ${JSON.stringify(bad)} is not saved`, async () => { + await renderReady([{ ...BRUSH, id: 50, icon: "lucide:dog" }]); + fireEvent.click(screen.getByTestId("definition-50")); + const s = sheet(); + fireEvent.change(emojiInput(s), { target: { value: bad } }); + + await save(s); + expect(api.writes()).toHaveLength(0); + expect(within(s).getByText(/Enter a single emoji/)).toBeTruthy(); + expect(screen.getByRole("dialog")).toBe(s); + }); + } + + it("prefills a fa: task and PATCHes an emoji replacement", async () => { + await renderReady([{ ...BRUSH, id: 51, icon: "fa:dog" }]); + fireEvent.click(screen.getByTestId("definition-51")); + const s = sheet(); + expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("true"); + expect(radio(s, "Icon", "Dog").getAttribute("aria-checked")).toBe("false"); + expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false"); + expect(emojiInput(s).value).toBe(""); + + fireEvent.change(emojiInput(s), { target: { value: "๐Ÿฑ" } }); + expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("false"); + await save(s); + const { method, path, body } = api.writes()[0]; + expect(method).toBe("PATCH"); + expect(path).toBe("/api/v1/admin/quest-definitions/51"); + expect((body as { icon: string }).icon).toBe("emoji:๐Ÿฑ"); + await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-51"))).toBe("emoji:๐Ÿฑ")); + }); + + it("prefills an emoji: task and PATCHes a Font Awesome replacement", async () => { + await renderReady([{ ...BRUSH, id: 52, icon: "emoji:๐Ÿถ" }]); + fireEvent.click(screen.getByTestId("definition-52")); + const s = sheet(); + expect(emojiInput(s).value).toBe("๐Ÿถ"); + expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("false"); + for (const name of ["Icon", "Font Awesome"]) { + const group = within(s).getByRole("radiogroup", { name }); + expect(within(group).queryAllByRole("radio", { checked: true })).toHaveLength(0); + } + + fireEvent.click(radio(s, "Font Awesome", "Pets")); + expect(emojiInput(s).value).toBe(""); + await save(s); + expect((api.writes()[0].body as { icon: string }).icon).toBe("fa:paw"); + await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-52"))).toBe("fa:paw")); + }); + + it("an unchanged emoji is sent as-is on edit", async () => { + await renderReady([{ ...BRUSH, id: 53, icon: "emoji:๐Ÿถ" }]); + fireEvent.click(screen.getByTestId("definition-53")); + const s = sheet(); + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Feed the dog" }, + }); + await save(s); + expect((api.writes()[0].body as { icon: string }).icon).toBe("emoji:๐Ÿถ"); + }); + + it("No icon and an emptied emoji field both clear the icon to null", async () => { + await renderReady([ + { ...BRUSH, id: 54, icon: "emoji:๐Ÿถ" }, + { ...BRUSH, id: 55, title: "Task 55", icon: "fa:dog" }, + ]); + fireEvent.click(screen.getByTestId("definition-54")); + let s = sheet(); + fireEvent.change(emojiInput(s), { target: { value: "" } }); + expect(radio(s, "Icon", "No icon").getAttribute("aria-checked")).toBe("true"); + await save(s); + expect((api.writes()[0].body as { icon: string | null }).icon).toBeNull(); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + + fireEvent.click(screen.getByTestId("definition-55")); + s = sheet(); + fireEvent.click(radio(s, "Icon", "No icon")); + expect(radio(s, "Font Awesome", "Dog").getAttribute("aria-checked")).toBe("false"); + await save(s); + expect((api.writes()[1].body as { icon: string | null }).icon).toBeNull(); + await waitFor(() => expect(rowGlyph(screen.getByTestId("definition-55"))).toBe("fallback")); + }); +}); + /* โ”€โ”€ Start date โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ function startDateInput(s: HTMLElement): HTMLInputElement { diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx index 92efd51..fcd174a 100644 --- a/admin/src/definitions/DefinitionsTab.tsx +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -46,6 +46,11 @@ import { import { DEFINITION_ICONS, DefinitionIcon, + FA_DEFINITION_ICONS, + emojiIconKey, + emojiIconText, + faIconKey, + faIconName, lucideIconKey, normalizeDefinitionIconName, } from "./definitionIcons"; @@ -84,8 +89,13 @@ 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. */ + /** + * The stored icon key (`lucide:`, `fa:` or `emoji:`); a legacy value + * outside the curated sets is kept until replaced. + */ icon: string | null; + /** The emoji field's text; typing in it sets `icon` to `emoji:`. */ + emoji: string; assigneeIds: number[]; repeats: Repeats; interval: string; @@ -126,6 +136,7 @@ function newDraft(activeChildren: AdminChild[]): Draft { return { title: "", icon: null, + emoji: "", assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [], repeats: "daily", interval: "1", @@ -151,6 +162,7 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D return { title: definition.title, icon: definition.icon, + emoji: emojiIconText(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], @@ -237,6 +249,9 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody | if (!title) return "Give the task a title."; if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child."; if (draft.windows.length === 0) return "Pick at least one window."; + if (draft.emoji && draft.icon === null) { + return "Enter a single emoji (no spaces or < > &), or clear the emoji field."; + } const rule = buildRule(draft); if (typeof rule === "string") return rule; const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map( @@ -575,10 +590,12 @@ function EditSheet({ @@ -594,13 +611,46 @@ function EditSheet({ aria-label={label} data-icon-option={name} className={on ? "defs-icon defs-icon--on" : "defs-icon"} - onClick={() => update({ icon: lucideIconKey(name) })} + onClick={() => update({ icon: lucideIconKey(name), emoji: "" })} > ); })} + + Font Awesome + +
+ {FA_DEFINITION_ICONS.map((icon) => { + const { name, label } = icon; + const on = faIconName(draft.icon) === name; + return ( + + ); + })} +
+ diff --git a/admin/src/definitions/definitionIcons.tsx b/admin/src/definitions/definitionIcons.tsx index 8343321..575dd9f 100644 --- a/admin/src/definitions/definitionIcons.tsx +++ b/admin/src/definitions/definitionIcons.tsx @@ -1,26 +1,47 @@ /** - * The curated Lucide set a definition's `icon` may name (ADMIN-SPEC ยง3.3). - * The names, labels and path data come from the shared icon registry + * The icons a definition's `icon` may name (ADMIN-SPEC ยง3.3): the curated + * Lucide set, the curated Font Awesome Free solid subset, or an emoji. The + * names, labels and path data come from the shared icon registry * (`src/icons/registry.generated.ts`, generated from * `tools/icons/curated-icons.json`) โ€” no CDN, no runtime fetch. The stored - * value is the namespaced `lucide:`; a legacy bare - * `` still names the same Lucide entry. + * value is namespaced โ€” `lucide:`, `fa:` or `emoji:` โ€” + * and a legacy bare `` still names the same Lucide entry. */ import { GlyphSvg } from "../icons/GlyphSvg"; -import { LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated"; +import { FA_ICONS, LUCIDE_ICONS, resolveIcon, type GlyphIcon } from "../icons/registry.generated"; import { TaskGlyph } from "./glyphs"; export type DefinitionIconEntry = GlyphIcon; export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = LUCIDE_ICONS; +export const FA_DEFINITION_ICONS: readonly DefinitionIconEntry[] = FA_ICONS; + const LUCIDE_PREFIX = "lucide:"; +const FA_PREFIX = "fa:"; +const EMOJI_PREFIX = "emoji:"; /** The canonical stored key for a Lucide choice, e.g. `lucide:dog`. */ export function lucideIconKey(name: string): string { return `${LUCIDE_PREFIX}${name}`; } +/** The canonical stored key for a Font Awesome choice, e.g. `fa:dog`. */ +export function faIconKey(name: string): string { + return `${FA_PREFIX}${name}`; +} + +/** + * The stored key for typed emoji text, e.g. `emoji:๐Ÿถ`. The raw text โ€” not + * trimmed, as the backend rejects any whitespace โ€” must pass the registry's + * emoji check (1-8 code points, no whitespace, control characters or `<`, + * `>`, `&`), else null. Only an empty field means "no icon". + */ +export function emojiIconKey(text: string): string | null { + const key = `${EMOJI_PREFIX}${text}`; + return resolveIcon(key).kind === "emoji" ? key : null; +} + /** * The Lucide name an icon value refers to: `lucide:dog` and a legacy bare * `dog` both give `dog`. Any other namespace (`fa:`, `emoji:`, unknown) and @@ -32,19 +53,38 @@ export function normalizeDefinitionIconName(value: string | null): string | null return value.includes(":") ? null : value; } +/** The Font Awesome name an `fa:` value refers to; anything else gives null. */ +export function faIconName(value: string | null): string | null { + return value?.startsWith(FA_PREFIX) ? value.slice(FA_PREFIX.length) : null; +} + +/** The emoji text an `emoji:` value holds; anything else gives "". */ +export function emojiIconText(value: string | null): string { + return value?.startsWith(EMOJI_PREFIX) ? value.slice(EMOJI_PREFIX.length) : ""; +} + /** - * The curated Lucide entry an icon value resolves to via the shared parser. - * The admin draws only Lucide glyphs for now; `fa:`, `emoji:`, unknown and - * null values give null. + * The curated glyph (Lucide or Font Awesome) an icon value resolves to via + * the shared parser; emoji, unknown and null values give null. */ export function findDefinitionIcon(value: string | null): DefinitionIconEntry | null { const icon = resolveIcon(value); - return icon.kind === "lucide" ? icon : null; + return icon.kind === "lucide" || icon.kind === "fa" ? icon : null; } -/** A definition's glyph; no icon, or one outside the Lucide subset, gets the generic task glyph. */ +/** + * A definition's icon: a Lucide or Font Awesome glyph, an emoji drawn as + * text, or โ€” for no icon or an unknown value โ€” the generic task glyph. + */ export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) { - const entry = findDefinitionIcon(name); - if (!entry) return ; - return ; + const icon = resolveIcon(name); + if (icon.kind === "fallback") return ; + if (icon.kind === "emoji") { + return ( + + ); + } + return ; }