diff --git a/.gitignore b/.gitignore index 42596752f..d7edf85b0 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..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 -- **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. - **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.test.tsx b/packages/graph-explorer/src/components/IconPicker.test.tsx new file mode 100644 index 000000000..1018ff82e --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.test.tsx @@ -0,0 +1,174 @@ +// @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 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(); + + 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..724f82225 --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.tsx @@ -0,0 +1,137 @@ +import { SearchIcon } from "lucide-react"; +import { DynamicIcon } from "lucide-react/dynamic"; +import { useState } from "react"; + +import { + allIconNamesSorted, + getLucideName, + type IconName, + toLucideIconRef, +} from "@/utils/lucideIcons"; + +import { + Button, + EmptyState, + EmptyStateContent, + EmptyStateDescription, + EmptyStateTitle, + Input, + Popover, + PopoverContent, + PopoverTrigger, +} from "."; + +const MAX_VISIBLE = 64; + +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 selectedName = getLucideName(currentIconUrl); + const filtered = filterIcons(search); + + function handleSelect(iconName: IconName) { + onSelect(toLucideIconRef(iconName), "image/svg+xml"); + setOpen(false); + setSearch(""); + } + + return ( + + + + + + setSearch(e.target.value)} + /> +
+ {filtered.map(name => ( + + ))} + {filtered.length === 0 && ( + + + No icons found + + No matching icons found. Try a broader search. + + + + )} +
+ {filtered.length >= MAX_VISIBLE && ( +

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

+ )} +
+
+ ); +} + +function IconButton({ + name, + selected, + onSelect, +}: { + name: IconName; + selected: boolean; + onSelect: (name: IconName) => void; +}) { + return ( + + ); +} + +function filterIcons(search: string) { + if (!search) return allIconNamesSorted.slice(0, MAX_VISIBLE); + const lower = search.toLowerCase(); + const results: IconName[] = []; + for (const name of allIconNamesSorted) { + 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..18b58222b 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,6 +9,7 @@ import { type VertexType, } from "@/core"; import { cn } from "@/utils"; +import { getLucideName, isValidLucideIconName } from "@/utils/lucideIcons"; import { SearchResultSymbol } from "./SearchResult"; @@ -20,6 +22,23 @@ interface Props { function VertexIcon({ vertexStyle, className, alt }: Props) { const altText = alt ?? `${vertexStyle.displayLabel ?? vertexStyle.type} icon`; + const lucideIconName = getLucideName(vertexStyle.iconUrl); + + if (lucideIconName) { + if (isValidLucideIconName(lucideIconName)) { + return ( + + ); + } else { + // Unknown lucide ref — don't fall through to img/SVG paths + return null; + } + } + 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(); + }); + + 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(" 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(); @@ -50,7 +64,10 @@ export async function renderNode( return null; } - if (vtConfig.iconImageType !== "image/svg+xml") { + if ( + !isLucideIconRef(vtConfig.iconUrl) && + vtConfig.iconImageType !== "image/svg+xml" + ) { return vtConfig.iconUrl; } 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/lucideIcons.test.ts b/packages/graph-explorer/src/utils/lucideIcons.test.ts new file mode 100644 index 000000000..ac69511ee --- /dev/null +++ b/packages/graph-explorer/src/utils/lucideIcons.test.ts @@ -0,0 +1,113 @@ +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 to a template literal", () => { + const url: string | undefined = "lucide:plane"; + expect(isLucideIconRef(url)).toBe(true); + }); +}); + +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/lucideIcons.ts b/packages/graph-explorer/src/utils/lucideIcons.ts new file mode 100644 index 000000000..8a6405155 --- /dev/null +++ b/packages/graph-explorer/src/utils/lucideIcons.ts @@ -0,0 +1,63 @@ +import dynamicIconImports from "lucide-react/dynamicIconImports"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** 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. + * 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, +): 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 && 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); +} + +/** Build a `lucide:` reference for storage. */ +export function toLucideIconRef(iconName: string): string { + return `${LUCIDE_PREFIX}${iconName}`; +} + +/** + * 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 { + if (!isValidLucideIconName(iconName)) { + return null; + } + try { + const { default: Icon } = await dynamicIconImports[iconName](); + return renderToStaticMarkup(createElement(Icon)); + } catch { + return null; + } +}