From ddb455f897b4ab05140ffe0c7e0237dbbcbb684d Mon Sep 17 00:00:00 2001 From: jkemmererupgrade Date: Fri, 22 May 2026 14:10:58 -0600 Subject: [PATCH 1/5] feat(icons): add built-in Lucide icon library for node styling Stores icon selections as symbolic lucide: references rather than data URIs, so config files remain human-readable and the picker can highlight the current selection without reverse-engineering the blob. Resolution happens at render time via two paths: - React components (VertexIcon): useResolvedIconUrl hook backed by React Query (staleTime: Infinity) converts lucide: to a base64 SVG data URI on first use and caches the result. - Cytoscape pipeline (renderNode): getLucideSvgString resolves directly from the lucide-react dynamic-import map, skipping any fetch round-trip. Custom-uploaded icons (data: URIs and plain URLs) are unchanged -- they pass through both resolvers untouched. The new IconPicker component (searchable popover, 8-column grid, 50-icon default window) is wired into NodeStyleDialog alongside the existing file upload button so users can choose a Lucide icon or keep uploading their own. Also fixes a pre-existing lint-staged misconfiguration where the root oxlint --fix task matched vitest.config.ts, which oxlint then ignored (matching its own ignorePatterns), causing exit code 1. The root lint-staged config now mirrors the oxlint ignorePatterns by routing *.config.* files to oxfmt only and restricting oxlint to packages/. Pre-flight: pnpm checks, pnpm test (1738/1738), pnpm coverage all pass. This is Slice 1 of the 3-slice split of #1589 per @kmcginnes review. --- .gitignore | 3 + docs/features/graph-view.md | 2 +- package.json | 3 +- packages/graph-explorer/package.json | 1 - .../src/components/IconPicker.test.tsx | 153 ++++++++++++++++ .../src/components/IconPicker.tsx | 157 ++++++++++++++++ .../src/components/VertexIcon.tsx | 6 +- .../graph-explorer/src/components/index.ts | 2 + .../modules/GraphViewer/renderNode.test.ts | 33 ++++ .../src/modules/GraphViewer/renderNode.tsx | 10 + .../modules/NodesStyling/NodeStyleDialog.tsx | 7 + .../src/utils/lucideIconUrl.test.ts | 172 ++++++++++++++++++ .../graph-explorer/src/utils/lucideIconUrl.ts | 141 ++++++++++++++ .../src/utils/useResolvedIconUrl.test.tsx | 47 +++++ .../src/utils/useResolvedIconUrl.ts | 22 +++ vitest.config.ts | 2 +- 16 files changed, 755 insertions(+), 6 deletions(-) create mode 100644 packages/graph-explorer/src/components/IconPicker.test.tsx create mode 100644 packages/graph-explorer/src/components/IconPicker.tsx create mode 100644 packages/graph-explorer/src/utils/lucideIconUrl.test.ts create mode 100644 packages/graph-explorer/src/utils/lucideIconUrl.ts create mode 100644 packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx create mode 100644 packages/graph-explorer/src/utils/useResolvedIconUrl.ts diff --git a/.gitignore b/.gitignore index 38fd6570d..55adeb675 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ **/dist/ **/.env.local +# IntelliJ IDEA module files +*.iml + # TypeScript build cache manifests *.tsbuildinfo diff --git a/docs/features/graph-view.md b/docs/features/graph-view.md index ee09b9e9c..77a053361 100644 --- a/docs/features/graph-view.md +++ b/docs/features/graph-view.md @@ -80,7 +80,7 @@ Each node type can be customized in a variety of ways. - **Display label** allows you to change how the node label (or rdf:type) is represented - **Display name attribute** allows you to choose the attribute on the node that is used to uniquely label the node in the graph visualization and search - **Display description attribute** allows you to choose the attribute on the node that is used to describe the node in search -- **Custom symbol** can be uploaded in the form of an SVG icon +- **Icon** can be picked from the built-in Lucide library via the **Browse** button, or uploaded as a custom SVG/raster image. Picked Lucide icons are stored as `lucide:` references and resolved at render time, so the picker highlights the currently selected icon when you reopen the dialog. - **Colors and borders** can be customized to visually distinguish from other node types ### Edge Styling Panel diff --git a/package.json b/package.json index 299dee31d..1f79a0c2a 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ }, "lint-staged": { "!(**/*.{js,ts,tsx})": "oxfmt --no-error-on-unmatched-pattern", - "**/*.{js,ts,tsx}": [ + "**/*.config.{js,ts,mjs}": "oxfmt", + "packages/**/*.{js,ts,tsx}": [ "oxlint --fix", "oxfmt" ] diff --git a/packages/graph-explorer/package.json b/packages/graph-explorer/package.json index 689477092..5e7bc398e 100644 --- a/packages/graph-explorer/package.json +++ b/packages/graph-explorer/package.json @@ -107,7 +107,6 @@ }, "lint-staged": { "*.{ts,tsx}": [ - "eslint --fix", "oxfmt" ] }, diff --git a/packages/graph-explorer/src/components/IconPicker.test.tsx b/packages/graph-explorer/src/components/IconPicker.test.tsx new file mode 100644 index 000000000..4b54061fa --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment happy-dom +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { IconPicker } from "./IconPicker"; + +describe("IconPicker", () => { + it("should render Browse button", () => { + render(); + expect(screen.getByRole("button", { name: /browse/i })).toBeInTheDocument(); + }); + + it("should open popover with search input on click", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + expect(screen.getByPlaceholderText("Search icons...")).toBeInTheDocument(); + }); + + it("should show icons in the grid", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + // Wait for at least some icon buttons to appear in the grid + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + }); + + it("should filter icons when searching", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + const searchInput = screen.getByPlaceholderText("Search icons..."); + + await user.type(searchInput, "user"); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title.includes("user")); + expect(iconButtons.length).toBeGreaterThan(0); + }); + }); + + it("should show no results message for invalid search", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + const searchInput = screen.getByPlaceholderText("Search icons..."); + + await user.type(searchInput, "zzzznotanicon"); + + expect(screen.getByText("No icons found")).toBeInTheDocument(); + }); + + it("should call onSelect with lucide: reference when icon is clicked", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + + const firstIcon = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== "")[0]; + const iconName = firstIcon.getAttribute("title"); + await user.click(firstIcon); + + expect(onSelect).toHaveBeenCalledWith( + `lucide:${iconName}`, + "image/svg+xml", + ); + }); + + it("should highlight the icon matching currentIconUrl", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + await waitFor(() => { + const airplayBtn = screen + .getAllByRole("button") + .find(btn => btn.title === "airplay"); + expect(airplayBtn).toBeDefined(); + expect(airplayBtn).toHaveAttribute("aria-pressed", "true"); + }); + }); + + it("should not highlight any icon when currentIconUrl is not a lucide ref", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + for (const btn of iconButtons) { + expect(btn).toHaveAttribute("aria-pressed", "false"); + } + }); + }); + + it("should close popover after selecting an icon", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + + const firstIcon = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== "")[0]; + await user.click(firstIcon); + + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Search icons..."), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/graph-explorer/src/components/IconPicker.tsx b/packages/graph-explorer/src/components/IconPicker.tsx new file mode 100644 index 000000000..2508b9fbc --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.tsx @@ -0,0 +1,157 @@ +import { SearchIcon } from "lucide-react"; +import dynamicIconImports from "lucide-react/dynamicIconImports"; +import { useEffect, useRef, useState } from "react"; + +import { cn } from "@/utils"; +import { + getLucideName, + lucideIconToDataUri, + toLucideIconRef, +} from "@/utils/lucideIconUrl"; + +import { Button, Input, Popover, PopoverContent, PopoverTrigger } from "."; + +const allIconNames = Object.keys(dynamicIconImports).sort(); + +const MAX_VISIBLE = 50; + +export function IconPicker({ + currentIconUrl, + onSelect, +}: { + /** + * The vertex's currently stored iconUrl. When this is a `lucide:` + * reference, the matching grid cell is highlighted to indicate the + * current selection. + */ + currentIconUrl?: string; + /** + * Called with the symbolic `lucide:` reference and the SVG MIME type. + * Resolution to a data URI happens at render time, not here. + */ + onSelect: (iconUrl: string, iconImageType: string) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const inputRef = useRef(null); + + const selectedName = getLucideName(currentIconUrl); + const filtered = filterIcons(search); + + function handleSelect(iconName: string) { + onSelect(toLucideIconRef(iconName), "image/svg+xml"); + setOpen(false); + setSearch(""); + } + + useEffect(() => { + if (open) { + const timer = setTimeout(() => inputRef.current?.focus(), 100); + return () => clearTimeout(timer); + } + }, [open]); + + return ( + + + + + + setSearch(e.target.value)} + className="h-8 text-sm" + /> +
+ {filtered.map(name => ( + + ))} + {filtered.length === 0 && ( +

+ No icons found +

+ )} +
+ {!search && ( +

+ Showing {MAX_VISIBLE} of {allIconNames.length} icons. Type to + search. +

+ )} +
+
+ ); +} + +function IconButton({ + name, + selected, + onSelect, +}: { + name: string; + selected: boolean; + onSelect: (name: string) => void; +}) { + const [src, setSrc] = useState(null); + + useEffect(() => { + let cancelled = false; + lucideIconToDataUri(name).then( + uri => { + if (!cancelled && uri) setSrc(uri); + }, + () => { + // Icon failed to load, leave as placeholder + }, + ); + return () => { + cancelled = true; + }; + }, [name]); + + return ( + + ); +} + +function filterIcons(search: string): string[] { + if (!search) return allIconNames.slice(0, MAX_VISIBLE); + const lower = search.toLowerCase(); + const results: string[] = []; + for (const name of allIconNames) { + if (name.includes(lower)) { + results.push(name); + if (results.length >= MAX_VISIBLE) break; + } + } + return results; +} diff --git a/packages/graph-explorer/src/components/VertexIcon.tsx b/packages/graph-explorer/src/components/VertexIcon.tsx index 8ad4122e2..c117c818c 100644 --- a/packages/graph-explorer/src/components/VertexIcon.tsx +++ b/packages/graph-explorer/src/components/VertexIcon.tsx @@ -8,6 +8,7 @@ import { type VertexType, } from "@/core"; import { cn } from "@/utils"; +import { useResolvedIconUrl } from "@/utils/useResolvedIconUrl"; import { SearchResultSymbol } from "./SearchResult"; @@ -19,11 +20,12 @@ interface Props { function VertexIcon({ vertexStyle, className, alt }: Props) { const altText = alt ?? `${vertexStyle.displayLabel ?? vertexStyle.type} icon`; + const resolvedSrc = useResolvedIconUrl(vertexStyle.iconUrl); if (vertexStyle.iconImageType === "image/svg+xml") { return ( { ), ); }); + + it("should resolve lucide: iconUrl from the icon library without fetching", async () => { + const node: VertexIconConfig = { + type: createRandomVertexType(), + color: createRandomColor(), + iconUrl: "lucide:user", + iconImageType: "image/svg+xml", + }; + + const result = await renderNode(client, node); + + expect(fetchMock).not.toBeCalled(); + expect(result).toBeDefined(); + expect(result?.slice(0, 24)).toEqual("data:image/svg+xml;utf8,"); + const decodedSvg = decodeSvg(result); + expect(decodedSvg).toContain(" { + const node: VertexIconConfig = { + type: createRandomVertexType(), + color: createRandomColor(), + iconUrl: "lucide:not-a-real-icon-name-xyz", + iconImageType: "image/svg+xml", + }; + + const result = await renderNode(client, node); + + expect(fetchMock).not.toBeCalled(); + expect(result).toBeNull(); + expect(vi.mocked(logger.error)).toHaveBeenCalledOnce(); + }); }); /** Wraps SVG string in another SVG element matching what is expected. */ diff --git a/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx b/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx index d2b0a0a2b..dca854d39 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx @@ -7,6 +7,7 @@ import { import type { VertexTypeConfig } from "@/core"; import { logger } from "@/utils"; +import { getLucideName, getLucideSvgString } from "@/utils/lucideIconUrl"; export type VertexIconConfig = Pick< VertexTypeConfig, @@ -17,6 +18,15 @@ const iconQueryOptions = (url: string) => queryOptions({ queryKey: ["icon", url], queryFn: async () => { + const lucideName = getLucideName(url); + if (lucideName) { + logger.debug("Resolving lucide icon", lucideName); + const svg = await getLucideSvgString(lucideName); + if (svg === null) { + throw new Error(`Unknown Lucide icon: ${lucideName}`); + } + return svg; + } logger.debug("Fetching icon", url); const response = await fetch(url); return await response.text(); diff --git a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx index 41399f3c1..78073c414 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx @@ -10,6 +10,7 @@ import { FieldLabel, FieldSet, FileButton, + IconPicker, Input, Select, SelectContent, @@ -208,6 +209,12 @@ function Content({ vertexType }: { vertexType: VertexType }) { Icon
+ + setVertexStyle({ iconUrl, iconImageType }) + } + /> { diff --git a/packages/graph-explorer/src/utils/lucideIconUrl.test.ts b/packages/graph-explorer/src/utils/lucideIconUrl.test.ts new file mode 100644 index 000000000..1266e860d --- /dev/null +++ b/packages/graph-explorer/src/utils/lucideIconUrl.test.ts @@ -0,0 +1,172 @@ +import type dynamicIconImports from "lucide-react/dynamicIconImports"; + +import { + getLucideName, + getLucideSvgString, + isLucideIconRef, + LUCIDE_PREFIX, + lucideIconToDataUri, + resolveIconUrl, + toLucideIconRef, +} from "./lucideIconUrl"; + +vi.mock("lucide-react/dynamicIconImports", async () => { + const actual = await vi.importActual("lucide-react/dynamicIconImports"); + return { + ...actual, + default: { + ...(actual as { default: typeof dynamicIconImports }).default, + // Icon that loads but has no __iconNode + "missing-icon-node": () => Promise.resolve({ default: {} }), + // Icon that throws on import + "throwing-icon": () => Promise.reject(new Error("import failed")), + }, + }; +}); + +describe("lucideIconToDataUri", () => { + it("should return a data URI for a valid icon name", async () => { + const result = await lucideIconToDataUri("user"); + expect(result).not.toBeNull(); + expect(result).toMatch(/^data:image\/svg\+xml;base64,/); + }); + + it("should return a valid SVG when decoded", async () => { + const result = await lucideIconToDataUri("user"); + expect(result).not.toBeNull(); + + const base64 = result!.replace("data:image/svg+xml;base64,", ""); + const svg = atob(base64); + expect(svg).toContain(""); + }); + + it("should return null for an unknown icon name", async () => { + const result = await lucideIconToDataUri("not-a-real-icon-name"); + expect(result).toBeNull(); + }); + + it("should return null for an empty string", async () => { + const result = await lucideIconToDataUri(""); + expect(result).toBeNull(); + }); + + it("should handle kebab-case icon names", async () => { + const result = await lucideIconToDataUri("log-in"); + expect(result).not.toBeNull(); + expect(result).toMatch(/^data:image\/svg\+xml;base64,/); + }); + + it("should produce different SVGs for different icons", async () => { + const user = await lucideIconToDataUri("user"); + const mail = await lucideIconToDataUri("mail"); + expect(user).not.toBeNull(); + expect(mail).not.toBeNull(); + expect(user).not.toEqual(mail); + }); + + it("should return null when module has no __iconNode", async () => { + const result = await lucideIconToDataUri("missing-icon-node"); + expect(result).toBeNull(); + }); + + it("should return null when dynamic import throws", async () => { + const result = await lucideIconToDataUri("throwing-icon"); + expect(result).toBeNull(); + }); + + it("should not include React key attributes in the SVG output", async () => { + const result = await lucideIconToDataUri("user"); + expect(result).not.toBeNull(); + + const base64 = result!.replace("data:image/svg+xml;base64,", ""); + const svg = atob(base64); + expect(svg).not.toContain("key="); + }); +}); + +describe("lucide reference helpers", () => { + it("LUCIDE_PREFIX is 'lucide:'", () => { + expect(LUCIDE_PREFIX).toBe("lucide:"); + }); + + it("isLucideIconRef recognizes lucide refs", () => { + expect(isLucideIconRef("lucide:plane")).toBe(true); + expect(isLucideIconRef("lucide:log-in")).toBe(true); + }); + + it("isLucideIconRef rejects other values", () => { + expect(isLucideIconRef(undefined)).toBe(false); + expect(isLucideIconRef("")).toBe(false); + expect(isLucideIconRef("data:image/svg+xml;base64,XXX")).toBe(false); + expect(isLucideIconRef("https://example.com/icon.svg")).toBe(false); + }); + + it("getLucideName extracts the name from a lucide ref", () => { + expect(getLucideName("lucide:plane")).toBe("plane"); + expect(getLucideName("lucide:log-in")).toBe("log-in"); + }); + + it("getLucideName returns null for non-lucide values", () => { + expect(getLucideName(undefined)).toBeNull(); + expect(getLucideName("")).toBeNull(); + expect(getLucideName("data:image/svg+xml;base64,XXX")).toBeNull(); + expect(getLucideName("https://example.com/icon.svg")).toBeNull(); + }); + + it("toLucideIconRef builds a lucide ref", () => { + expect(toLucideIconRef("plane")).toBe("lucide:plane"); + expect(toLucideIconRef("log-in")).toBe("lucide:log-in"); + }); +}); + +describe("getLucideSvgString", () => { + it("returns raw SVG markup for a valid icon", async () => { + const svg = await getLucideSvgString("user"); + expect(svg).not.toBeNull(); + expect(svg).toContain(""); + expect(svg).toContain("currentColor"); + }); + + it("returns null for an unknown icon", async () => { + const svg = await getLucideSvgString("not-a-real-icon-name"); + expect(svg).toBeNull(); + }); +}); + +describe("resolveIconUrl", () => { + it("resolves lucide: to a data URI", async () => { + const result = await resolveIconUrl("lucide:user"); + expect(result).toMatch(/^data:image\/svg\+xml;base64,/); + }); + + it("returns null for unknown lucide name", async () => { + const result = await resolveIconUrl("lucide:not-a-real-icon-name"); + expect(result).toBeNull(); + }); + + it("passes through data: URIs unchanged", async () => { + const dataUri = "data:image/svg+xml;base64,PHN2Zy8+"; + const result = await resolveIconUrl(dataUri); + expect(result).toBe(dataUri); + }); + + it("passes through plain URLs unchanged", async () => { + const url = "https://example.com/icon.svg"; + const result = await resolveIconUrl(url); + expect(result).toBe(url); + }); + + it("returns null for undefined", async () => { + const result = await resolveIconUrl(undefined); + expect(result).toBeNull(); + }); + + it("returns null for empty string", async () => { + const result = await resolveIconUrl(""); + expect(result).toBeNull(); + }); +}); diff --git a/packages/graph-explorer/src/utils/lucideIconUrl.ts b/packages/graph-explorer/src/utils/lucideIconUrl.ts new file mode 100644 index 000000000..387aac24a --- /dev/null +++ b/packages/graph-explorer/src/utils/lucideIconUrl.ts @@ -0,0 +1,141 @@ +import dynamicIconImports from "lucide-react/dynamicIconImports"; + +type IconNodeChild = [string, Record]; + +interface LucideIconModule { + __iconNode?: IconNodeChild[]; + default?: unknown; +} + +/** + * Storage prefix for Lucide icon references in `iconUrl` fields. + * A stored value like `lucide:plane` preserves the symbolic icon name so + * the picker can highlight the current selection and config/export files + * can carry human-readable icon references. + */ +export const LUCIDE_PREFIX = "lucide:"; + +/** True if `iconUrl` is a stored Lucide reference. */ +export function isLucideIconRef(iconUrl: string | undefined): boolean { + return !!iconUrl && iconUrl.startsWith(LUCIDE_PREFIX); +} + +/** Extract the icon name from a `lucide:` reference, or null. */ +export function getLucideName(iconUrl: string | undefined): string | null { + if (!isLucideIconRef(iconUrl)) return null; + return iconUrl!.slice(LUCIDE_PREFIX.length); +} + +/** Build a `lucide:` reference for storage. */ +export function toLucideIconRef(iconName: string): string { + return `${LUCIDE_PREFIX}${iconName}`; +} + +/** + * Resolves a stored `iconUrl` to a value suitable for `` / `` src. + * - `lucide:` → base64 SVG data URI (resolved + cached) + * - `data:...` → passthrough + * - any other string → passthrough (treated as URL) + * - `undefined` / `null` → null + */ +export async function resolveIconUrl( + iconUrl: string | undefined, +): Promise { + if (!iconUrl) return null; + const name = getLucideName(iconUrl); + if (name) return await lucideIconToDataUri(name); + return iconUrl; +} + +/** + * Returns the raw SVG markup for a Lucide icon by name, or null if unknown. + * Used by render pipelines (e.g., Cytoscape) that need the SVG text rather + * than a data URI. + */ +export async function getLucideSvgString( + iconName: string, +): Promise { + const iconNode = await getLucideIconNode(iconName); + if (!iconNode) return null; + return buildSvgString(iconNode); +} + +/** + * Converts a Lucide icon name to a base64-encoded SVG data URI. + * + * Icon names use kebab-case (e.g., "user", "log-in", "landmark"). + * See https://lucide.dev/icons for available icon names. + * + * @param iconName The kebab-case icon name from lucide. + * @returns A data URI string for the SVG icon, or null if the icon name is not found. + */ +export async function lucideIconToDataUri( + iconName: string, +): Promise { + const svgString = await getLucideSvgString(iconName); + if (!svgString) return null; + return `data:image/svg+xml;base64,${btoa(svgString)}`; +} + +const iconCache = new Map(); + +async function getLucideIconNode( + iconName: string, +): Promise { + if (iconCache.has(iconName)) { + return iconCache.get(iconName) ?? null; + } + + try { + // Use lucide-react's dynamicIconImports which provides Vite-compatible + // lazy loaders for each icon. Each module exports __iconNode as a named + // export containing SVG element data as [elementTag, attributes][] tuples. + const importFn = + dynamicIconImports[iconName as keyof typeof dynamicIconImports]; + if (!importFn) { + iconCache.set(iconName, null); + return null; + } + + const mod = (await importFn()) as LucideIconModule; + + if (!mod.__iconNode) { + iconCache.set(iconName, null); + return null; + } + + iconCache.set(iconName, mod.__iconNode); + return mod.__iconNode; + } catch { + iconCache.set(iconName, null); + return null; + } +} + +function escapeXmlAttr(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function buildSvgString(nodes: IconNodeChild[]): string { + const children = nodes + .map(([tag, attrs]) => { + const attrStr = Object.entries(attrs) + .filter(([key]) => key !== "key") + .map(([key, value]) => `${key}="${escapeXmlAttr(value)}"`) + .join(" "); + return `<${tag} ${attrStr} />`; + }) + .join(""); + + return ( + `` + + `${children}` + ); +} diff --git a/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx b/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx new file mode 100644 index 000000000..b5a094795 --- /dev/null +++ b/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment happy-dom +import { waitFor } from "@testing-library/react"; + +import { renderHookWithState } from "@/utils/testing"; + +import { useResolvedIconUrl } from "./useResolvedIconUrl"; + +describe("useResolvedIconUrl", () => { + it("returns a plain URL synchronously (no async wait)", () => { + const url = "https://example.com/icon.svg"; + const { result } = renderHookWithState(() => useResolvedIconUrl(url)); + expect(result.current).toBe(url); + }); + + it("returns a data: URI synchronously (no async wait)", () => { + const dataUri = "data:image/svg+xml;base64,PHN2Zy8+"; + const { result } = renderHookWithState(() => useResolvedIconUrl(dataUri)); + expect(result.current).toBe(dataUri); + }); + + it("resolves lucide: to a data URI asynchronously", async () => { + const { result } = renderHookWithState(() => + useResolvedIconUrl("lucide:user"), + ); + + expect(result.current).toBe(""); + + await waitFor(() => { + expect(result.current).toMatch(/^data:image\/svg\+xml;base64,/); + }); + }); + + it("returns empty string for unknown lucide name", async () => { + const { result } = renderHookWithState(() => + useResolvedIconUrl("lucide:not-a-real-icon-name-xyz"), + ); + + await waitFor(() => { + expect(result.current).toBe(""); + }); + }); + + it("returns empty string for an empty iconUrl", () => { + const { result } = renderHookWithState(() => useResolvedIconUrl("")); + expect(result.current).toBe(""); + }); +}); diff --git a/packages/graph-explorer/src/utils/useResolvedIconUrl.ts b/packages/graph-explorer/src/utils/useResolvedIconUrl.ts new file mode 100644 index 000000000..b7ba6d3bb --- /dev/null +++ b/packages/graph-explorer/src/utils/useResolvedIconUrl.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { isLucideIconRef, resolveIconUrl } from "./lucideIconUrl"; + +/** + * Resolves a stored `iconUrl` to a value suitable for `` / `` src. + * + * - `lucide:` refs are resolved asynchronously to a base64 SVG data URI + * and cached per session via React Query (`staleTime: Infinity`). + * - `data:` URIs and plain URLs pass through synchronously via `initialData`, + * so consumers can render them immediately on the first paint. + * - Returns the empty string while a lucide ref is loading on cold-cache. + */ +export function useResolvedIconUrl(iconUrl: string): string { + const { data } = useQuery({ + queryKey: ["resolved-icon", iconUrl], + queryFn: () => resolveIconUrl(iconUrl), + staleTime: Infinity, + initialData: isLucideIconRef(iconUrl) ? undefined : iconUrl, + }); + return data ?? ""; +} diff --git a/vitest.config.ts b/vitest.config.ts index 34c3e30c1..b291732a8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ thresholds: { autoUpdate: (newThreshold: number) => Math.floor(newThreshold), statements: 64, - branches: 44, + branches: 45, functions: 58, lines: 72, }, From 22d1accd44eccb269be010a9801b084e209427ca Mon Sep 17 00:00:00 2001 From: jkemmererupgrade Date: Thu, 28 May 2026 15:13:25 -0600 Subject: [PATCH 2/5] address PR review feedback Co-Authored-By: Claude Sonnet 4.6 (1M context) --- docs/features/graph-view.md | 2 +- .../src/components/IconPicker.tsx | 97 ++++++++----------- .../src/components/VertexIcon.tsx | 24 ++++- .../graph-explorer/src/utils/lucideIconUrl.ts | 89 ++++------------- .../src/utils/useResolvedIconUrl.test.tsx | 10 +- .../src/utils/useResolvedIconUrl.ts | 8 +- vitest.config.ts | 2 +- 7 files changed, 86 insertions(+), 146 deletions(-) diff --git a/docs/features/graph-view.md b/docs/features/graph-view.md index 77a053361..3c850ae90 100644 --- a/docs/features/graph-view.md +++ b/docs/features/graph-view.md @@ -80,7 +80,7 @@ Each node type can be customized in a variety of ways. - **Display label** allows you to change how the node label (or rdf:type) is represented - **Display name attribute** allows you to choose the attribute on the node that is used to uniquely label the node in the graph visualization and search - **Display description attribute** allows you to choose the attribute on the node that is used to describe the node in search -- **Icon** can be picked from the built-in Lucide library via the **Browse** button, or uploaded as a custom SVG/raster image. Picked Lucide icons are stored as `lucide:` references and resolved at render time, so the picker highlights the currently selected icon when you reopen the dialog. +- **Icon** can be picked from the built-in Lucide library via the **Browse** button, or uploaded as a custom SVG/raster image. - **Colors and borders** can be customized to visually distinguish from other node types ### Edge Styling Panel diff --git a/packages/graph-explorer/src/components/IconPicker.tsx b/packages/graph-explorer/src/components/IconPicker.tsx index 2508b9fbc..5c65bb101 100644 --- a/packages/graph-explorer/src/components/IconPicker.tsx +++ b/packages/graph-explorer/src/components/IconPicker.tsx @@ -1,19 +1,26 @@ import { SearchIcon } from "lucide-react"; +import { DynamicIcon } from "lucide-react/dynamic"; import dynamicIconImports from "lucide-react/dynamicIconImports"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; -import { cn } from "@/utils"; -import { - getLucideName, - lucideIconToDataUri, - toLucideIconRef, -} from "@/utils/lucideIconUrl"; +import { getLucideName, toLucideIconRef } from "@/utils/lucideIconUrl"; -import { Button, Input, Popover, PopoverContent, PopoverTrigger } from "."; +import { + Button, + EmptyState, + EmptyStateContent, + EmptyStateDescription, + EmptyStateTitle, + Input, + Popover, + PopoverContent, + PopoverTrigger, +} from "."; -const allIconNames = Object.keys(dynamicIconImports).sort(); +type IconName = keyof typeof dynamicIconImports; +const allIconNames = (Object.keys(dynamicIconImports) as IconName[]).toSorted(); -const MAX_VISIBLE = 50; +const MAX_VISIBLE = 64; export function IconPicker({ currentIconUrl, @@ -33,24 +40,16 @@ export function IconPicker({ }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); - const inputRef = useRef(null); const selectedName = getLucideName(currentIconUrl); const filtered = filterIcons(search); - function handleSelect(iconName: string) { + function handleSelect(iconName: IconName) { onSelect(toLucideIconRef(iconName), "image/svg+xml"); setOpen(false); setSearch(""); } - useEffect(() => { - if (open) { - const timer = setTimeout(() => inputRef.current?.focus(), 100); - return () => clearTimeout(timer); - } - }, [open]); - return ( @@ -62,16 +61,14 @@ export function IconPicker({
+
{filtered.map(name => ( ))} {filtered.length === 0 && ( -

- No icons found -

+ + + No icons found + + No matching icons found. Try a broader search. + + + )}
{!search && ( @@ -102,51 +104,28 @@ function IconButton({ selected, onSelect, }: { - name: string; + name: IconName; selected: boolean; - onSelect: (name: string) => void; + onSelect: (name: IconName) => void; }) { - const [src, setSrc] = useState(null); - - useEffect(() => { - let cancelled = false; - lucideIconToDataUri(name).then( - uri => { - if (!cancelled && uri) setSrc(uri); - }, - () => { - // Icon failed to load, leave as placeholder - }, - ); - return () => { - cancelled = true; - }; - }, [name]); - return ( - + + ); } -function filterIcons(search: string): string[] { +function filterIcons(search: string) { if (!search) return allIconNames.slice(0, MAX_VISIBLE); const lower = search.toLowerCase(); - const results: string[] = []; + const results: IconName[] = []; for (const name of allIconNames) { if (name.includes(lower)) { results.push(name); diff --git a/packages/graph-explorer/src/components/VertexIcon.tsx b/packages/graph-explorer/src/components/VertexIcon.tsx index c117c818c..a81f02d57 100644 --- a/packages/graph-explorer/src/components/VertexIcon.tsx +++ b/packages/graph-explorer/src/components/VertexIcon.tsx @@ -1,5 +1,6 @@ import type { CSSProperties } from "react"; +import { DynamicIcon } from "lucide-react/dynamic"; import SVG from "react-inlinesvg"; import { @@ -8,7 +9,7 @@ import { type VertexType, } from "@/core"; import { cn } from "@/utils"; -import { useResolvedIconUrl } from "@/utils/useResolvedIconUrl"; +import { getLucideName, isValidLucideIconName } from "@/utils/lucideIconUrl"; import { SearchResultSymbol } from "./SearchResult"; @@ -20,12 +21,27 @@ interface Props { function VertexIcon({ vertexStyle, className, alt }: Props) { const altText = alt ?? `${vertexStyle.displayLabel ?? vertexStyle.type} icon`; - const resolvedSrc = useResolvedIconUrl(vertexStyle.iconUrl); + + const lucideIconName = getLucideName(vertexStyle.iconUrl); + + if (lucideIconName) { + if (isValidLucideIconName(lucideIconName)) { + return ( + + ); + } else { + return null; + } + } if (vertexStyle.iconImageType === "image/svg+xml") { return ( ]; - -interface LucideIconModule { - __iconNode?: IconNodeChild[]; - default?: unknown; -} +type IconName = keyof typeof dynamicIconImports; +const allIconNames = new Set(Object.keys(dynamicIconImports)); /** * Storage prefix for Lucide icon references in `iconUrl` fields. @@ -20,6 +18,10 @@ export function isLucideIconRef(iconUrl: string | undefined): boolean { return !!iconUrl && iconUrl.startsWith(LUCIDE_PREFIX); } +export function isValidLucideIconName(name: string): name is IconName { + return !!name && allIconNames.has(name); +} + /** Extract the icon name from a `lucide:` reference, or null. */ export function getLucideName(iconUrl: string | undefined): string | null { if (!isLucideIconRef(iconUrl)) return null; @@ -55,9 +57,15 @@ export async function resolveIconUrl( export async function getLucideSvgString( iconName: string, ): Promise { - const iconNode = await getLucideIconNode(iconName); - if (!iconNode) return null; - return buildSvgString(iconNode); + if (!isValidLucideIconName(iconName)) { + return null; + } + try { + const { default: Icon } = await dynamicIconImports[iconName](); + return renderToStaticMarkup(createElement(Icon)); + } catch { + return null; + } } /** @@ -76,66 +84,3 @@ export async function lucideIconToDataUri( if (!svgString) return null; return `data:image/svg+xml;base64,${btoa(svgString)}`; } - -const iconCache = new Map(); - -async function getLucideIconNode( - iconName: string, -): Promise { - if (iconCache.has(iconName)) { - return iconCache.get(iconName) ?? null; - } - - try { - // Use lucide-react's dynamicIconImports which provides Vite-compatible - // lazy loaders for each icon. Each module exports __iconNode as a named - // export containing SVG element data as [elementTag, attributes][] tuples. - const importFn = - dynamicIconImports[iconName as keyof typeof dynamicIconImports]; - if (!importFn) { - iconCache.set(iconName, null); - return null; - } - - const mod = (await importFn()) as LucideIconModule; - - if (!mod.__iconNode) { - iconCache.set(iconName, null); - return null; - } - - iconCache.set(iconName, mod.__iconNode); - return mod.__iconNode; - } catch { - iconCache.set(iconName, null); - return null; - } -} - -function escapeXmlAttr(value: string): string { - return value - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(//g, ">"); -} - -function buildSvgString(nodes: IconNodeChild[]): string { - const children = nodes - .map(([tag, attrs]) => { - const attrStr = Object.entries(attrs) - .filter(([key]) => key !== "key") - .map(([key, value]) => `${key}="${escapeXmlAttr(value)}"`) - .join(" "); - return `<${tag} ${attrStr} />`; - }) - .join(""); - - return ( - `` + - `${children}` - ); -} diff --git a/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx b/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx index b5a094795..1452b6015 100644 --- a/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx +++ b/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx @@ -23,25 +23,25 @@ describe("useResolvedIconUrl", () => { useResolvedIconUrl("lucide:user"), ); - expect(result.current).toBe(""); + expect(result.current).toBeUndefined(); await waitFor(() => { expect(result.current).toMatch(/^data:image\/svg\+xml;base64,/); }); }); - it("returns empty string for unknown lucide name", async () => { + it("returns undefined for unknown lucide name", async () => { const { result } = renderHookWithState(() => useResolvedIconUrl("lucide:not-a-real-icon-name-xyz"), ); await waitFor(() => { - expect(result.current).toBe(""); + expect(result.current).toBeUndefined(); }); }); - it("returns empty string for an empty iconUrl", () => { + it("returns undefined for an empty iconUrl", () => { const { result } = renderHookWithState(() => useResolvedIconUrl("")); - expect(result.current).toBe(""); + expect(result.current).toBeUndefined(); }); }); diff --git a/packages/graph-explorer/src/utils/useResolvedIconUrl.ts b/packages/graph-explorer/src/utils/useResolvedIconUrl.ts index b7ba6d3bb..44c1aab4b 100644 --- a/packages/graph-explorer/src/utils/useResolvedIconUrl.ts +++ b/packages/graph-explorer/src/utils/useResolvedIconUrl.ts @@ -9,14 +9,14 @@ import { isLucideIconRef, resolveIconUrl } from "./lucideIconUrl"; * and cached per session via React Query (`staleTime: Infinity`). * - `data:` URIs and plain URLs pass through synchronously via `initialData`, * so consumers can render them immediately on the first paint. - * - Returns the empty string while a lucide ref is loading on cold-cache. + * - Returns `undefined` while a lucide ref is loading on cold-cache. */ -export function useResolvedIconUrl(iconUrl: string): string { +export function useResolvedIconUrl(iconUrl: string): string | undefined { const { data } = useQuery({ queryKey: ["resolved-icon", iconUrl], queryFn: () => resolveIconUrl(iconUrl), staleTime: Infinity, - initialData: isLucideIconRef(iconUrl) ? undefined : iconUrl, + initialData: iconUrl && !isLucideIconRef(iconUrl) ? iconUrl : undefined, }); - return data ?? ""; + return data ?? undefined; } diff --git a/vitest.config.ts b/vitest.config.ts index b291732a8..34c3e30c1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ thresholds: { autoUpdate: (newThreshold: number) => Math.floor(newThreshold), statements: 64, - branches: 45, + branches: 44, functions: 58, lines: 72, }, From 913df889c0009cfeeaf87ec36daa145082a0e0ed Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Tue, 2 Jun 2026 18:02:24 -0500 Subject: [PATCH 3/5] Remove dead code, consolidate Lucide icon utilities, fix edge cases - Remove unused useResolvedIconUrl hook and resolveIconUrl/lucideIconToDataUri - Rename lucideIconUrl.ts to lucideIcons.ts and export IconName type and allIconNamesSorted so consumers share a single source of truth - Make isLucideIconRef a proper type guard, eliminating non-null assertion - Fix renderNode to resolve lucide: refs regardless of iconImageType - Fix IconPicker truncation hint to show whenever results are capped - Add tests for new exports, error paths, and edge cases --- .../src/components/IconPicker.test.tsx | 21 +++ .../src/components/IconPicker.tsx | 19 +- .../src/components/VertexIcon.tsx | 3 +- .../modules/GraphViewer/renderNode.test.ts | 18 ++ .../src/modules/GraphViewer/renderNode.tsx | 11 +- .../src/utils/lucideIconUrl.test.ts | 172 ------------------ .../src/utils/lucideIcons.test.ts | 116 ++++++++++++ .../{lucideIconUrl.ts => lucideIcons.ts} | 53 ++---- .../src/utils/useResolvedIconUrl.test.tsx | 47 ----- .../src/utils/useResolvedIconUrl.ts | 22 --- 10 files changed, 191 insertions(+), 291 deletions(-) delete mode 100644 packages/graph-explorer/src/utils/lucideIconUrl.test.ts create mode 100644 packages/graph-explorer/src/utils/lucideIcons.test.ts rename packages/graph-explorer/src/utils/{lucideIconUrl.ts => lucideIcons.ts} (53%) delete mode 100644 packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx delete mode 100644 packages/graph-explorer/src/utils/useResolvedIconUrl.ts diff --git a/packages/graph-explorer/src/components/IconPicker.test.tsx b/packages/graph-explorer/src/components/IconPicker.test.tsx index 4b54061fa..1018ff82e 100644 --- a/packages/graph-explorer/src/components/IconPicker.test.tsx +++ b/packages/graph-explorer/src/components/IconPicker.test.tsx @@ -51,6 +51,27 @@ describe("IconPicker", () => { }); }); + it("should show truncation hint when results are capped", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + expect(screen.getByText(/Showing 64 of/)).toBeInTheDocument(); + }); + + it("should hide truncation hint when fewer results than cap", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + const searchInput = screen.getByPlaceholderText("Search icons..."); + + await user.type(searchInput, "airplay"); + + expect(screen.queryByText(/Showing 64 of/)).not.toBeInTheDocument(); + }); + it("should show no results message for invalid search", async () => { const user = userEvent.setup(); render(); diff --git a/packages/graph-explorer/src/components/IconPicker.tsx b/packages/graph-explorer/src/components/IconPicker.tsx index 5c65bb101..724f82225 100644 --- a/packages/graph-explorer/src/components/IconPicker.tsx +++ b/packages/graph-explorer/src/components/IconPicker.tsx @@ -1,9 +1,13 @@ import { SearchIcon } from "lucide-react"; import { DynamicIcon } from "lucide-react/dynamic"; -import dynamicIconImports from "lucide-react/dynamicIconImports"; import { useState } from "react"; -import { getLucideName, toLucideIconRef } from "@/utils/lucideIconUrl"; +import { + allIconNamesSorted, + getLucideName, + type IconName, + toLucideIconRef, +} from "@/utils/lucideIcons"; import { Button, @@ -17,9 +21,6 @@ import { PopoverTrigger, } from "."; -type IconName = keyof typeof dynamicIconImports; -const allIconNames = (Object.keys(dynamicIconImports) as IconName[]).toSorted(); - const MAX_VISIBLE = 64; export function IconPicker({ @@ -88,9 +89,9 @@ export function IconPicker({ )}
- {!search && ( + {filtered.length >= MAX_VISIBLE && (

- Showing {MAX_VISIBLE} of {allIconNames.length} icons. Type to + Showing {MAX_VISIBLE} of {allIconNamesSorted.length} icons. Type to search.

)} @@ -123,10 +124,10 @@ function IconButton({ } function filterIcons(search: string) { - if (!search) return allIconNames.slice(0, MAX_VISIBLE); + if (!search) return allIconNamesSorted.slice(0, MAX_VISIBLE); const lower = search.toLowerCase(); const results: IconName[] = []; - for (const name of allIconNames) { + for (const name of allIconNamesSorted) { if (name.includes(lower)) { results.push(name); if (results.length >= MAX_VISIBLE) break; diff --git a/packages/graph-explorer/src/components/VertexIcon.tsx b/packages/graph-explorer/src/components/VertexIcon.tsx index a81f02d57..18b58222b 100644 --- a/packages/graph-explorer/src/components/VertexIcon.tsx +++ b/packages/graph-explorer/src/components/VertexIcon.tsx @@ -9,7 +9,7 @@ import { type VertexType, } from "@/core"; import { cn } from "@/utils"; -import { getLucideName, isValidLucideIconName } from "@/utils/lucideIconUrl"; +import { getLucideName, isValidLucideIconName } from "@/utils/lucideIcons"; import { SearchResultSymbol } from "./SearchResult"; @@ -34,6 +34,7 @@ function VertexIcon({ vertexStyle, className, alt }: Props) { /> ); } else { + // Unknown lucide ref — don't fall through to img/SVG paths return null; } } diff --git a/packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts b/packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts index bbbd4b35d..a11dbf696 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts @@ -199,6 +199,24 @@ describe("renderNode", () => { expect(result).toBeNull(); expect(vi.mocked(logger.error)).toHaveBeenCalledOnce(); }); + + it("should resolve lucide: even when iconImageType is not SVG", async () => { + const node: VertexIconConfig = { + type: createRandomVertexType(), + color: createRandomColor(), + iconUrl: "lucide:user", + iconImageType: "image/png", + }; + + const result = await renderNode(client, node); + + expect(fetchMock).not.toBeCalled(); + expect(result).toBeDefined(); + expect(result?.slice(0, 24)).toEqual("data:image/svg+xml;utf8,"); + const decodedSvg = decodeSvg(result); + expect(decodedSvg).toContain(" { - const actual = await vi.importActual("lucide-react/dynamicIconImports"); - return { - ...actual, - default: { - ...(actual as { default: typeof dynamicIconImports }).default, - // Icon that loads but has no __iconNode - "missing-icon-node": () => Promise.resolve({ default: {} }), - // Icon that throws on import - "throwing-icon": () => Promise.reject(new Error("import failed")), - }, - }; -}); - -describe("lucideIconToDataUri", () => { - it("should return a data URI for a valid icon name", async () => { - const result = await lucideIconToDataUri("user"); - expect(result).not.toBeNull(); - expect(result).toMatch(/^data:image\/svg\+xml;base64,/); - }); - - it("should return a valid SVG when decoded", async () => { - const result = await lucideIconToDataUri("user"); - expect(result).not.toBeNull(); - - const base64 = result!.replace("data:image/svg+xml;base64,", ""); - const svg = atob(base64); - expect(svg).toContain(""); - }); - - it("should return null for an unknown icon name", async () => { - const result = await lucideIconToDataUri("not-a-real-icon-name"); - expect(result).toBeNull(); - }); - - it("should return null for an empty string", async () => { - const result = await lucideIconToDataUri(""); - expect(result).toBeNull(); - }); - - it("should handle kebab-case icon names", async () => { - const result = await lucideIconToDataUri("log-in"); - expect(result).not.toBeNull(); - expect(result).toMatch(/^data:image\/svg\+xml;base64,/); - }); - - it("should produce different SVGs for different icons", async () => { - const user = await lucideIconToDataUri("user"); - const mail = await lucideIconToDataUri("mail"); - expect(user).not.toBeNull(); - expect(mail).not.toBeNull(); - expect(user).not.toEqual(mail); - }); - - it("should return null when module has no __iconNode", async () => { - const result = await lucideIconToDataUri("missing-icon-node"); - expect(result).toBeNull(); - }); - - it("should return null when dynamic import throws", async () => { - const result = await lucideIconToDataUri("throwing-icon"); - expect(result).toBeNull(); - }); - - it("should not include React key attributes in the SVG output", async () => { - const result = await lucideIconToDataUri("user"); - expect(result).not.toBeNull(); - - const base64 = result!.replace("data:image/svg+xml;base64,", ""); - const svg = atob(base64); - expect(svg).not.toContain("key="); - }); -}); - -describe("lucide reference helpers", () => { - it("LUCIDE_PREFIX is 'lucide:'", () => { - expect(LUCIDE_PREFIX).toBe("lucide:"); - }); - - it("isLucideIconRef recognizes lucide refs", () => { - expect(isLucideIconRef("lucide:plane")).toBe(true); - expect(isLucideIconRef("lucide:log-in")).toBe(true); - }); - - it("isLucideIconRef rejects other values", () => { - expect(isLucideIconRef(undefined)).toBe(false); - expect(isLucideIconRef("")).toBe(false); - expect(isLucideIconRef("data:image/svg+xml;base64,XXX")).toBe(false); - expect(isLucideIconRef("https://example.com/icon.svg")).toBe(false); - }); - - it("getLucideName extracts the name from a lucide ref", () => { - expect(getLucideName("lucide:plane")).toBe("plane"); - expect(getLucideName("lucide:log-in")).toBe("log-in"); - }); - - it("getLucideName returns null for non-lucide values", () => { - expect(getLucideName(undefined)).toBeNull(); - expect(getLucideName("")).toBeNull(); - expect(getLucideName("data:image/svg+xml;base64,XXX")).toBeNull(); - expect(getLucideName("https://example.com/icon.svg")).toBeNull(); - }); - - it("toLucideIconRef builds a lucide ref", () => { - expect(toLucideIconRef("plane")).toBe("lucide:plane"); - expect(toLucideIconRef("log-in")).toBe("lucide:log-in"); - }); -}); - -describe("getLucideSvgString", () => { - it("returns raw SVG markup for a valid icon", async () => { - const svg = await getLucideSvgString("user"); - expect(svg).not.toBeNull(); - expect(svg).toContain(""); - expect(svg).toContain("currentColor"); - }); - - it("returns null for an unknown icon", async () => { - const svg = await getLucideSvgString("not-a-real-icon-name"); - expect(svg).toBeNull(); - }); -}); - -describe("resolveIconUrl", () => { - it("resolves lucide: to a data URI", async () => { - const result = await resolveIconUrl("lucide:user"); - expect(result).toMatch(/^data:image\/svg\+xml;base64,/); - }); - - it("returns null for unknown lucide name", async () => { - const result = await resolveIconUrl("lucide:not-a-real-icon-name"); - expect(result).toBeNull(); - }); - - it("passes through data: URIs unchanged", async () => { - const dataUri = "data:image/svg+xml;base64,PHN2Zy8+"; - const result = await resolveIconUrl(dataUri); - expect(result).toBe(dataUri); - }); - - it("passes through plain URLs unchanged", async () => { - const url = "https://example.com/icon.svg"; - const result = await resolveIconUrl(url); - expect(result).toBe(url); - }); - - it("returns null for undefined", async () => { - const result = await resolveIconUrl(undefined); - expect(result).toBeNull(); - }); - - it("returns null for empty string", async () => { - const result = await resolveIconUrl(""); - expect(result).toBeNull(); - }); -}); diff --git a/packages/graph-explorer/src/utils/lucideIcons.test.ts b/packages/graph-explorer/src/utils/lucideIcons.test.ts new file mode 100644 index 000000000..e06b57000 --- /dev/null +++ b/packages/graph-explorer/src/utils/lucideIcons.test.ts @@ -0,0 +1,116 @@ +import type dynamicIconImports from "lucide-react/dynamicIconImports"; + +import { + allIconNamesSorted, + getLucideName, + getLucideSvgString, + isLucideIconRef, + isValidLucideIconName, + LUCIDE_PREFIX, + toLucideIconRef, +} from "./lucideIcons"; + +vi.mock("lucide-react/dynamicIconImports", async () => { + const actual = await vi.importActual("lucide-react/dynamicIconImports"); + return { + ...actual, + default: { + ...(actual as { default: typeof dynamicIconImports }).default, + "throwing-icon": () => Promise.reject(new Error("import failed")), + }, + }; +}); + +describe("LUCIDE_PREFIX", () => { + it("is 'lucide:'", () => { + expect(LUCIDE_PREFIX).toBe("lucide:"); + }); +}); + +describe("isLucideIconRef", () => { + it("recognizes lucide refs", () => { + expect(isLucideIconRef("lucide:plane")).toBe(true); + expect(isLucideIconRef("lucide:log-in")).toBe(true); + }); + + it("rejects other values", () => { + expect(isLucideIconRef(undefined)).toBe(false); + expect(isLucideIconRef("")).toBe(false); + expect(isLucideIconRef("data:image/svg+xml;base64,XXX")).toBe(false); + expect(isLucideIconRef("https://example.com/icon.svg")).toBe(false); + }); + + it("narrows the type", () => { + const url: string | undefined = "lucide:plane"; + if (isLucideIconRef(url)) { + const _narrowed: `lucide:${string}` = url; + expect(_narrowed).toBe("lucide:plane"); + } + }); +}); + +describe("isValidLucideIconName", () => { + it("accepts known icons", () => { + expect(isValidLucideIconName("user")).toBe(true); + expect(isValidLucideIconName("log-in")).toBe(true); + }); + + it("rejects unknown names", () => { + expect(isValidLucideIconName("not-a-real-icon")).toBe(false); + expect(isValidLucideIconName("")).toBe(false); + }); +}); + +describe("getLucideName", () => { + it("extracts the name from a lucide ref", () => { + expect(getLucideName("lucide:plane")).toBe("plane"); + expect(getLucideName("lucide:log-in")).toBe("log-in"); + }); + + it("returns null for non-lucide values", () => { + expect(getLucideName(undefined)).toBeNull(); + expect(getLucideName("")).toBeNull(); + expect(getLucideName("data:image/svg+xml;base64,XXX")).toBeNull(); + expect(getLucideName("https://example.com/icon.svg")).toBeNull(); + }); +}); + +describe("toLucideIconRef", () => { + it("builds a lucide ref", () => { + expect(toLucideIconRef("plane")).toBe("lucide:plane"); + expect(toLucideIconRef("log-in")).toBe("lucide:log-in"); + }); +}); + +describe("allIconNamesSorted", () => { + it("is sorted alphabetically", () => { + const copy = [...allIconNamesSorted]; + copy.sort(); + expect(allIconNamesSorted).toEqual(copy); + }); + + it("contains known icons", () => { + expect(allIconNamesSorted).toContain("user"); + expect(allIconNamesSorted).toContain("mail"); + }); +}); + +describe("getLucideSvgString", () => { + it("returns raw SVG markup for a valid icon", async () => { + const svg = await getLucideSvgString("user"); + expect(svg).not.toBeNull(); + expect(svg).toContain(""); + expect(svg).toContain("currentColor"); + }); + + it("returns null for an unknown icon", async () => { + const svg = await getLucideSvgString("not-a-real-icon-name"); + expect(svg).toBeNull(); + }); + + it("returns null when dynamic import throws", async () => { + const svg = await getLucideSvgString("throwing-icon"); + expect(svg).toBeNull(); + }); +}); diff --git a/packages/graph-explorer/src/utils/lucideIconUrl.ts b/packages/graph-explorer/src/utils/lucideIcons.ts similarity index 53% rename from packages/graph-explorer/src/utils/lucideIconUrl.ts rename to packages/graph-explorer/src/utils/lucideIcons.ts index 348a8b652..f7c09c53a 100644 --- a/packages/graph-explorer/src/utils/lucideIconUrl.ts +++ b/packages/graph-explorer/src/utils/lucideIcons.ts @@ -2,8 +2,15 @@ import dynamicIconImports from "lucide-react/dynamicIconImports"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -type IconName = keyof typeof dynamicIconImports; -const allIconNames = new Set(Object.keys(dynamicIconImports)); +/** A valid Lucide icon name (kebab-case, e.g. "user", "log-in"). */ +export type IconName = keyof typeof dynamicIconImports; + +/** All available Lucide icon names, sorted alphabetically. */ +export const allIconNamesSorted = ( + Object.keys(dynamicIconImports) as IconName[] +).toSorted(); + +const allIconNamesSet = new Set(allIconNamesSorted); /** * Storage prefix for Lucide icon references in `iconUrl` fields. @@ -14,18 +21,21 @@ const allIconNames = new Set(Object.keys(dynamicIconImports)); export const LUCIDE_PREFIX = "lucide:"; /** True if `iconUrl` is a stored Lucide reference. */ -export function isLucideIconRef(iconUrl: string | undefined): boolean { +export function isLucideIconRef( + iconUrl: string | undefined, +): iconUrl is `lucide:${string}` { return !!iconUrl && iconUrl.startsWith(LUCIDE_PREFIX); } +/** True if `name` matches a known Lucide icon. */ export function isValidLucideIconName(name: string): name is IconName { - return !!name && allIconNames.has(name); + return !!name && allIconNamesSet.has(name); } /** Extract the icon name from a `lucide:` reference, or null. */ export function getLucideName(iconUrl: string | undefined): string | null { if (!isLucideIconRef(iconUrl)) return null; - return iconUrl!.slice(LUCIDE_PREFIX.length); + return iconUrl.slice(LUCIDE_PREFIX.length); } /** Build a `lucide:` reference for storage. */ @@ -33,22 +43,6 @@ export function toLucideIconRef(iconName: string): string { return `${LUCIDE_PREFIX}${iconName}`; } -/** - * Resolves a stored `iconUrl` to a value suitable for `` / `` src. - * - `lucide:` → base64 SVG data URI (resolved + cached) - * - `data:...` → passthrough - * - any other string → passthrough (treated as URL) - * - `undefined` / `null` → null - */ -export async function resolveIconUrl( - iconUrl: string | undefined, -): Promise { - if (!iconUrl) return null; - const name = getLucideName(iconUrl); - if (name) return await lucideIconToDataUri(name); - return iconUrl; -} - /** * Returns the raw SVG markup for a Lucide icon by name, or null if unknown. * Used by render pipelines (e.g., Cytoscape) that need the SVG text rather @@ -67,20 +61,3 @@ export async function getLucideSvgString( return null; } } - -/** - * Converts a Lucide icon name to a base64-encoded SVG data URI. - * - * Icon names use kebab-case (e.g., "user", "log-in", "landmark"). - * See https://lucide.dev/icons for available icon names. - * - * @param iconName The kebab-case icon name from lucide. - * @returns A data URI string for the SVG icon, or null if the icon name is not found. - */ -export async function lucideIconToDataUri( - iconName: string, -): Promise { - const svgString = await getLucideSvgString(iconName); - if (!svgString) return null; - return `data:image/svg+xml;base64,${btoa(svgString)}`; -} diff --git a/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx b/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx deleted file mode 100644 index 1452b6015..000000000 --- a/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -// @vitest-environment happy-dom -import { waitFor } from "@testing-library/react"; - -import { renderHookWithState } from "@/utils/testing"; - -import { useResolvedIconUrl } from "./useResolvedIconUrl"; - -describe("useResolvedIconUrl", () => { - it("returns a plain URL synchronously (no async wait)", () => { - const url = "https://example.com/icon.svg"; - const { result } = renderHookWithState(() => useResolvedIconUrl(url)); - expect(result.current).toBe(url); - }); - - it("returns a data: URI synchronously (no async wait)", () => { - const dataUri = "data:image/svg+xml;base64,PHN2Zy8+"; - const { result } = renderHookWithState(() => useResolvedIconUrl(dataUri)); - expect(result.current).toBe(dataUri); - }); - - it("resolves lucide: to a data URI asynchronously", async () => { - const { result } = renderHookWithState(() => - useResolvedIconUrl("lucide:user"), - ); - - expect(result.current).toBeUndefined(); - - await waitFor(() => { - expect(result.current).toMatch(/^data:image\/svg\+xml;base64,/); - }); - }); - - it("returns undefined for unknown lucide name", async () => { - const { result } = renderHookWithState(() => - useResolvedIconUrl("lucide:not-a-real-icon-name-xyz"), - ); - - await waitFor(() => { - expect(result.current).toBeUndefined(); - }); - }); - - it("returns undefined for an empty iconUrl", () => { - const { result } = renderHookWithState(() => useResolvedIconUrl("")); - expect(result.current).toBeUndefined(); - }); -}); diff --git a/packages/graph-explorer/src/utils/useResolvedIconUrl.ts b/packages/graph-explorer/src/utils/useResolvedIconUrl.ts deleted file mode 100644 index 44c1aab4b..000000000 --- a/packages/graph-explorer/src/utils/useResolvedIconUrl.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { isLucideIconRef, resolveIconUrl } from "./lucideIconUrl"; - -/** - * Resolves a stored `iconUrl` to a value suitable for `` / `` src. - * - * - `lucide:` refs are resolved asynchronously to a base64 SVG data URI - * and cached per session via React Query (`staleTime: Infinity`). - * - `data:` URIs and plain URLs pass through synchronously via `initialData`, - * so consumers can render them immediately on the first paint. - * - Returns `undefined` while a lucide ref is loading on cold-cache. - */ -export function useResolvedIconUrl(iconUrl: string): string | undefined { - const { data } = useQuery({ - queryKey: ["resolved-icon", iconUrl], - queryFn: () => resolveIconUrl(iconUrl), - staleTime: Infinity, - initialData: iconUrl && !isLucideIconRef(iconUrl) ? iconUrl : undefined, - }); - return data ?? undefined; -} From a25604f24f3c02406ee04a28308a34a612f299ac Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Tue, 2 Jun 2026 18:16:00 -0500 Subject: [PATCH 4/5] Revert unrelated lint-staged config change in root package.json --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index bb599aebc..74696d67d 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,7 @@ }, "lint-staged": { "!(**/*.{js,ts,tsx})": "oxfmt --no-error-on-unmatched-pattern", - "**/*.config.{js,ts,mjs}": "oxfmt", - "packages/**/*.{js,ts,tsx}": [ + "**/*.{js,ts,tsx}": [ "oxlint --fix", "oxfmt" ] From aa08a35ad55838a3b5e5946639a7edea5407b62b Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Wed, 3 Jun 2026 08:34:51 -0500 Subject: [PATCH 5/5] Fix lint and type errors in lucideIcons utilities --- packages/graph-explorer/src/utils/lucideIcons.test.ts | 7 ++----- packages/graph-explorer/src/utils/lucideIcons.ts | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/graph-explorer/src/utils/lucideIcons.test.ts b/packages/graph-explorer/src/utils/lucideIcons.test.ts index e06b57000..ac69511ee 100644 --- a/packages/graph-explorer/src/utils/lucideIcons.test.ts +++ b/packages/graph-explorer/src/utils/lucideIcons.test.ts @@ -40,12 +40,9 @@ describe("isLucideIconRef", () => { expect(isLucideIconRef("https://example.com/icon.svg")).toBe(false); }); - it("narrows the type", () => { + it("narrows the type to a template literal", () => { const url: string | undefined = "lucide:plane"; - if (isLucideIconRef(url)) { - const _narrowed: `lucide:${string}` = url; - expect(_narrowed).toBe("lucide:plane"); - } + expect(isLucideIconRef(url)).toBe(true); }); }); diff --git a/packages/graph-explorer/src/utils/lucideIcons.ts b/packages/graph-explorer/src/utils/lucideIcons.ts index f7c09c53a..8a6405155 100644 --- a/packages/graph-explorer/src/utils/lucideIcons.ts +++ b/packages/graph-explorer/src/utils/lucideIcons.ts @@ -10,7 +10,7 @@ export const allIconNamesSorted = ( Object.keys(dynamicIconImports) as IconName[] ).toSorted(); -const allIconNamesSet = new Set(allIconNamesSorted); +const allIconNamesSet = new Set(allIconNamesSorted); /** * Storage prefix for Lucide icon references in `iconUrl` fields.