From ddb455f897b4ab05140ffe0c7e0237dbbcbb684d Mon Sep 17 00:00:00 2001 From: jkemmererupgrade Date: Fri, 22 May 2026 14:10:58 -0600 Subject: [PATCH 1/2] 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 c9fa43c6d0b546fbfec46d9fb0807bf856de7fa7 Mon Sep 17 00:00:00 2001 From: jkemmererupgrade Date: Wed, 27 May 2026 08:08:18 -0600 Subject: [PATCH 2/2] feat(settings): add styling import / export / reset UI Add three buttons to the General Settings page: - Export: downloads current vertex and edge styling as graph-explorer-styling.json - Import: loads a JSON file, applies it as the live styling baseline, and stores it in defaultStylingAtom so per-type Reset on the node style dialog restores to imported values rather than application defaults - Reset All: reverts every vertex and edge to the imported baseline (or application defaults if no file has been imported this session) Introduce defaultStyling.ts with a Zod schema (DefaultStylingSchema), parseDefaultStyling, resolveDefaultStyling (converts lucide: shorthand to iconUrl references established by the Lucide icon picker in PR #1777), and userStylingToExportFormat. The file format round-trips cleanly via lucide: refs. Add mergeDefaultsIntoUserStyling to userPreferences.ts so imported baselines fill gaps for unstyled types without overwriting user customisations. Update toJsonFileData with an optional indent param (defaults unchanged) so the exported file is human-readable. Add unit tests for the schema, parse/resolve pipeline, hooks, and reset behaviour. Update docs/features/settings.md and docs/features/graph-view.md accordingly. Slice 2 of the 3-slice split of #1589. Stacks on #1777 (Lucide icon picker). --- docs/features/graph-view.md | 1 + docs/features/settings.md | 3 + .../core/StateProvider/configuration.test.ts | 29 ++ .../core/StateProvider/defaultStylingAtom.ts | 15 + .../src/core/StateProvider/index.ts | 1 + .../StateProvider/userPreferences.test.ts | 167 ++++++++ .../src/core/StateProvider/userPreferences.ts | 53 ++- .../src/core/defaultStyling.test.ts | 386 ++++++++++++++++++ .../graph-explorer/src/core/defaultStyling.ts | 248 +++++++++++ .../src/routes/Settings/SettingsGeneral.tsx | 77 +++- .../Settings/useExportStylingFile.test.tsx | 89 ++++ .../routes/Settings/useExportStylingFile.ts | 24 ++ .../Settings/useImportStylingFile.test.tsx | 129 ++++++ .../routes/Settings/useImportStylingFile.ts | 48 +++ packages/graph-explorer/src/utils/fileData.ts | 4 +- .../src/utils/testing/DbState.ts | 24 +- vitest.config.ts | 2 +- 17 files changed, 1291 insertions(+), 9 deletions(-) create mode 100644 packages/graph-explorer/src/core/StateProvider/defaultStylingAtom.ts create mode 100644 packages/graph-explorer/src/core/defaultStyling.test.ts create mode 100644 packages/graph-explorer/src/core/defaultStyling.ts create mode 100644 packages/graph-explorer/src/routes/Settings/useExportStylingFile.test.tsx create mode 100644 packages/graph-explorer/src/routes/Settings/useExportStylingFile.ts create mode 100644 packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx create mode 100644 packages/graph-explorer/src/routes/Settings/useImportStylingFile.ts diff --git a/docs/features/graph-view.md b/docs/features/graph-view.md index 77a053361..3881d67f3 100644 --- a/docs/features/graph-view.md +++ b/docs/features/graph-view.md @@ -82,6 +82,7 @@ Each node type can be customized in a variety of ways. - **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. - **Colors and borders** can be customized to visually distinguish from other node types +- **Reset to Default** restores this node type's styling. If a styling file has been imported via Settings, the imported values for this type are restored; otherwise the application's hardcoded defaults are used. ### Edge Styling Panel diff --git a/docs/features/settings.md b/docs/features/settings.md index 511245f85..4c2e02ec7 100644 --- a/docs/features/settings.md +++ b/docs/features/settings.md @@ -7,6 +7,9 @@ - **Default Neighbor Expansion Limit:** This setting will allow you to enable or disable the default limit applied during neighbor expansion. This applies to both double click expansion and the expand sidebar. This setting can be overridden by a similar setting on the connection itself. - **Save Configuration:** This action will export all the configuration data within the Graph Explorer local database. This will not store any data from the connected graph databases. However, the export may contain the shape of the schema for your databases and the connection URL. - **Load Configuration:** This action will replace all the Graph Explorer configuration data you currently have with the data in the provided configuration file. This is a destructive act and can not be undone. It is **strongly** suggested that you perform a **Save Configuration** action before performing a **Load Configuration** action to preserve any existing configuration data. +- **Export Styling:** Exports the current node and edge styling as a pretty-printed JSON file (`graph-explorer-styling.json`). The file uses symbolic icon references like `"iconUrl": "lucide:plane"` so it's human-readable and version-controllable, and round-trips cleanly through Import. Share it with teammates or check it into a repo. +- **Import Styling:** Loads a styling JSON file and applies it. Imported values replace your current styling and become the new baseline for **Reset to Default** behavior. The file format accepts both an `iconUrl` field (full reference like `"lucide:plane"`, a `data:` URI, or a plain URL) and an `icon` shorthand (e.g., `"icon": "user"`) which is converted to `"lucide:user"` on import. +- **Reset All Styling:** Resets every node and edge style. If a styling file has been imported in the current session, all types are restored to those imported values. Otherwise, styling reverts to the application's hardcoded defaults. ## About diff --git a/packages/graph-explorer/src/core/StateProvider/configuration.test.ts b/packages/graph-explorer/src/core/StateProvider/configuration.test.ts index c71b6ba13..e7271caea 100644 --- a/packages/graph-explorer/src/core/StateProvider/configuration.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/configuration.test.ts @@ -261,6 +261,35 @@ describe("mergedConfiguration", () => { expect(actualEtConfig?.displayLabel).toEqual(customDisplayLabel); }); + it("should apply styling from userStyling (which includes merged defaults)", () => { + const config = createRandomRawConfiguration(); + const schema = createRandomSchema(); + // In the new architecture, defaults from defaultStyling.json are + // merged into userStyling at load time + const styling: UserStyling = { + vertices: schema.vertices.map(v => ({ + type: v.type, + color: "#FF0000", + })), + edges: schema.edges.map(e => ({ + type: e.type, + lineColor: "#00FF00", + })), + }; + const result = mergeConfiguration(schema, config, styling); + + for (const v of result.schema?.vertices ?? []) { + const style = styling.vertices?.find(s => s.type === v.type); + assert(style); + expect(v.color).toBe("#FF0000"); + } + for (const e of result.schema?.edges ?? []) { + const style = styling.edges?.find(s => s.type === e.type); + assert(style); + expect(e.lineColor).toBe("#00FF00"); + } + }); + it("should patch displayNameAttribute to be 'types' when it was 'type'", () => { const etConfig = createRandomEdgeTypeConfig(); diff --git a/packages/graph-explorer/src/core/StateProvider/defaultStylingAtom.ts b/packages/graph-explorer/src/core/StateProvider/defaultStylingAtom.ts new file mode 100644 index 000000000..a92e8651b --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/defaultStylingAtom.ts @@ -0,0 +1,15 @@ +import { atom } from "jotai"; + +import type { UserStyling } from "./userPreferences"; + +/** + * Read-only reference copy of the resolved styling from defaultStyling.json. + * + * On load, the file values are written into userStylingAtom (for types without + * existing overrides). This atom is kept only as a reference so that per-type + * "Reset to Default" can restore the file's original values. + * + * NOT persisted to LocalForage. Re-fetched each session. Remains null when + * no defaultStyling.json is mounted. + */ +export const defaultStylingAtom = atom(null); diff --git a/packages/graph-explorer/src/core/StateProvider/index.ts b/packages/graph-explorer/src/core/StateProvider/index.ts index 438483344..113fd2342 100644 --- a/packages/graph-explorer/src/core/StateProvider/index.ts +++ b/packages/graph-explorer/src/core/StateProvider/index.ts @@ -1,5 +1,6 @@ export * from "./appStore"; export * from "./configuration"; +export * from "./defaultStylingAtom"; export * from "./displayAttribute"; export * from "./displayEdge"; export * from "./displayTypeConfigs"; diff --git a/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts index 3197dff95..8bec96347 100644 --- a/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts @@ -10,6 +10,7 @@ import { defaultVertexPreferences, edgePreferencesAtom, type EdgePreferencesStorageModel, + mergeDefaultsIntoUserStyling, useEdgeStyling, useVertexStyling, vertexPreferencesAtom, @@ -343,6 +344,172 @@ describe("useEdgeStyling", () => { }); }); +describe("default styling", () => { + it("should apply default styling when no user pref exists", () => { + const dbState = new DbState(); + dbState.setDefaultStyling({ + vertices: [ + { type: createVertexType("test"), color: "red", shape: "diamond" }, + ], + }); + + const { result } = renderHookWithState( + () => useVertexStyling(createVertexType("test")), + dbState, + ); + + expect(result.current.vertexStyle.color).toBe("red"); + expect(result.current.vertexStyle.shape).toBe("diamond"); + }); + + it("should let user prefs override default styling", () => { + const dbState = new DbState(); + dbState.setDefaultStyling({ + vertices: [ + { type: createVertexType("test"), color: "red", shape: "diamond" }, + ], + }); + dbState.addVertexStyle(createVertexType("test"), { color: "blue" }); + + const { result } = renderHookWithState( + () => useVertexStyling(createVertexType("test")), + dbState, + ); + + // User pref overrides default styling color + expect(result.current.vertexStyle.color).toBe("blue"); + // Default styling shape still applies since user didn't override it + expect(result.current.vertexStyle.shape).toBe("diamond"); + }); + + it("should reveal default styling after reset", () => { + const dbState = new DbState(); + dbState.setDefaultStyling({ + vertices: [{ type: createVertexType("test"), color: "red" }], + }); + dbState.addVertexStyle(createVertexType("test"), { color: "blue" }); + + const { result } = renderHookWithState( + () => useVertexStyling(createVertexType("test")), + dbState, + ); + + expect(result.current.vertexStyle.color).toBe("blue"); + + act(() => result.current.resetVertexStyle()); + + // After reset, default styling color should be visible + expect(result.current.vertexStyle.color).toBe("red"); + }); + + it("should fall through to hardcoded defaults when no default styling", () => { + const dbState = new DbState(); + // No default styling set + + const { result } = renderHookWithState( + () => useVertexStyling(createVertexType("test")), + dbState, + ); + + expect(result.current.vertexStyle).toStrictEqual( + createExpectedVertex({ type: createVertexType("test") }), + ); + }); + + it("should apply default edge styling", () => { + const dbState = new DbState(); + dbState.setDefaultStyling({ + edges: [ + { type: createEdgeType("test"), lineColor: "green", lineThickness: 5 }, + ], + }); + + const { result } = renderHookWithState( + () => useEdgeStyling(createEdgeType("test")), + dbState, + ); + + expect(result.current.edgeStyle.lineColor).toBe("green"); + expect(result.current.edgeStyle.lineThickness).toBe(5); + }); + + it("should let user edge prefs override default edge styling", () => { + const dbState = new DbState(); + dbState.setDefaultStyling({ + edges: [ + { type: createEdgeType("test"), lineColor: "green", lineThickness: 5 }, + ], + }); + dbState.addEdgeStyle(createEdgeType("test"), { lineColor: "red" }); + + const { result } = renderHookWithState( + () => useEdgeStyling(createEdgeType("test")), + dbState, + ); + + expect(result.current.edgeStyle.lineColor).toBe("red"); + expect(result.current.edgeStyle.lineThickness).toBe(5); + }); +}); + +describe("mergeDefaultsIntoUserStyling", () => { + it("should add default types when user has none", () => { + const result = mergeDefaultsIntoUserStyling( + {}, + { + vertices: [{ type: createVertexType("A"), color: "red" }], + edges: [{ type: createEdgeType("B"), lineColor: "green" }], + }, + ); + expect(result.vertices).toHaveLength(1); + expect(result.vertices![0].color).toBe("red"); + expect(result.edges).toHaveLength(1); + expect(result.edges![0].lineColor).toBe("green"); + }); + + it("should merge properties when user has partial override", () => { + const result = mergeDefaultsIntoUserStyling( + { + vertices: [{ type: createVertexType("A"), color: "blue" }], + }, + { + vertices: [ + { type: createVertexType("A"), color: "red", shape: "diamond" }, + ], + }, + ); + expect(result.vertices).toHaveLength(1); + expect(result.vertices![0].color).toBe("blue"); // user wins + expect(result.vertices![0].shape).toBe("diamond"); // default fills in + }); + + it("should not modify types not in defaults", () => { + const result = mergeDefaultsIntoUserStyling( + { + vertices: [{ type: createVertexType("A"), color: "blue" }], + edges: [{ type: createEdgeType("X"), lineColor: "red" }], + }, + { + vertices: [{ type: createVertexType("B"), color: "green" }], + }, + ); + expect(result.vertices).toHaveLength(2); + expect(result.vertices![0].color).toBe("blue"); + expect(result.vertices![1].color).toBe("green"); + expect(result.edges).toHaveLength(1); + expect(result.edges![0].lineColor).toBe("red"); + }); + + it("should handle empty defaults", () => { + const input = { + vertices: [{ type: createVertexType("A"), color: "blue" }], + }; + const result = mergeDefaultsIntoUserStyling(input, {}); + expect(result.vertices).toHaveLength(1); + expect(result.edges).toHaveLength(0); + }); +}); + describe("useDeferredAtom integration", () => { it("should handle multiple rapid updates correctly", () => { const dbState = new DbState(); diff --git a/packages/graph-explorer/src/core/StateProvider/userPreferences.ts b/packages/graph-explorer/src/core/StateProvider/userPreferences.ts index d9f0dfe9d..7832800df 100644 --- a/packages/graph-explorer/src/core/StateProvider/userPreferences.ts +++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.ts @@ -9,6 +9,7 @@ import DEFAULT_ICON_URL from "@/utils/defaultIconUrl"; import type { EdgeType, VertexType } from "../entities"; +import { defaultStylingAtom } from "./defaultStylingAtom"; import { useActiveSchema } from "./schema"; import { userStylingAtom } from "./storageAtoms"; @@ -158,6 +159,38 @@ export type UserStyling = { edges?: Array; }; +/** + * Merges an imported styling baseline into the user styling. + * Existing user values win via spread order; imported values only fill in + * gaps for types the user hasn't explicitly styled. + */ +export function mergeDefaultsIntoUserStyling( + userStyling: UserStyling, + defaults: UserStyling, +): UserStyling { + const vertices = [...(userStyling.vertices ?? [])]; + for (const v of defaults.vertices ?? []) { + const existingIndex = vertices.findIndex(e => e.type === v.type); + if (existingIndex >= 0) { + vertices[existingIndex] = { ...v, ...vertices[existingIndex] }; + } else { + vertices.push(v); + } + } + + const edges = [...(userStyling.edges ?? [])]; + for (const e of defaults.edges ?? []) { + const existingIndex = edges.findIndex(x => x.type === e.type); + if (existingIndex >= 0) { + edges[existingIndex] = { ...e, ...edges[existingIndex] }; + } else { + edges.push(e); + } + } + + return { vertices, edges }; +} + /** Vertex preferences indexed by type for O(1) lookup with default fallback. */ export const vertexPreferencesAtom = atom(get => { const userStyling = get(userStylingAtom); @@ -260,6 +293,7 @@ type UpdatedVertexStyle = Partial>; */ export function useVertexStyling(type: VertexType) { const setAllStyling = useSetAtom(userStylingAtom); + const defaultStyling = useAtomValue(defaultStylingAtom); const vertexStyle = useVertexPreferences(type); const setVertexStyle = (updatedStyle: UpdatedVertexStyle) => @@ -283,9 +317,17 @@ export function useVertexStyling(type: VertexType) { const resetVertexStyle = () => setAllStyling(prev => { + // Restore from the imported baseline if one exists, otherwise drop the + // entry entirely (which falls back to the hardcoded defaults). + const defaultForType = defaultStyling?.vertices?.find( + v => v.type === type, + ); + const withoutCurrent = prev.vertices?.filter(v => v.type !== type) ?? []; return { ...prev, - vertices: prev.vertices?.filter(v => v.type !== type), + vertices: defaultForType + ? [...withoutCurrent, defaultForType] + : withoutCurrent, }; }); @@ -306,6 +348,7 @@ type UpdatedEdgeStyle = Omit; */ export function useEdgeStyling(type: EdgeType) { const setAllStyling = useSetAtom(userStylingAtom); + const defaultStyling = useAtomValue(defaultStylingAtom); const edgeStyle = useEdgePreferences(type); const setEdgeStyle = (updatedStyle: UpdatedEdgeStyle) => @@ -329,9 +372,15 @@ export function useEdgeStyling(type: EdgeType) { const resetEdgeStyle = () => setAllStyling(prev => { + // Restore from the imported baseline if one exists, otherwise drop the + // entry entirely (which falls back to the hardcoded defaults). + const defaultForType = defaultStyling?.edges?.find(e => e.type === type); + const withoutCurrent = prev.edges?.filter(e => e.type !== type) ?? []; return { ...prev, - edges: prev.edges?.filter(v => v.type !== type), + edges: defaultForType + ? [...withoutCurrent, defaultForType] + : withoutCurrent, }; }); diff --git a/packages/graph-explorer/src/core/defaultStyling.test.ts b/packages/graph-explorer/src/core/defaultStyling.test.ts new file mode 100644 index 000000000..926b136c5 --- /dev/null +++ b/packages/graph-explorer/src/core/defaultStyling.test.ts @@ -0,0 +1,386 @@ +// @vitest-environment happy-dom +import { + DefaultStylingSchema, + parseDefaultStyling, + resolveDefaultStyling, + userStylingToExportFormat, +} from "./defaultStyling"; +import { createEdgeType, createVertexType } from "./entities"; + +describe("DefaultStylingSchema", () => { + it("should accept a valid complete config", () => { + const data = { + vertices: { + User: { + color: "#1565C0", + icon: "user", + shape: "ellipse", + backgroundOpacity: 0.4, + borderWidth: 2, + borderColor: "#000000", + borderStyle: "solid", + }, + }, + edges: { + OWNS: { + lineColor: "#2E7D32", + lineThickness: 3, + lineStyle: "dashed", + sourceArrowStyle: "none", + targetArrowStyle: "triangle", + }, + }, + }; + + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should accept an empty object", () => { + const result = DefaultStylingSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("should accept partial vertex entries", () => { + const data = { + vertices: { + User: { color: "#1565C0" }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should accept partial edge entries", () => { + const data = { + edges: { + OWNS: { lineColor: "#2E7D32" }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should accept vertex entries with zero values", () => { + const data = { + vertices: { + User: { backgroundOpacity: 0, borderWidth: 0 }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + assert(result.success); + expect(result.data.vertices?.User.backgroundOpacity).toBe(0); + expect(result.data.vertices?.User.borderWidth).toBe(0); + }); + + it("should reject unknown top-level properties", () => { + const data = { unknownProp: "value" }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject unknown vertex properties", () => { + const data = { + vertices: { + User: { unknownProp: "value" }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject invalid backgroundOpacity", () => { + const data = { + vertices: { + User: { backgroundOpacity: 2 }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject invalid borderStyle", () => { + const data = { + vertices: { + User: { borderStyle: "wavy" }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject invalid shape", () => { + const data = { + vertices: { + User: { shape: "banana" }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should accept valid shape values", () => { + const shapes = [ + "ellipse", + "rectangle", + "round-rectangle", + "diamond", + "star", + ]; + for (const shape of shapes) { + const data = { vertices: { User: { shape } } }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(true); + } + }); + + it("should reject invalid arrow style", () => { + const data = { + edges: { + OWNS: { targetArrowStyle: "star" }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(false); + }); +}); + +describe("parseDefaultStyling", () => { + it("should return parsed data for valid input", () => { + const data = { + vertices: { + User: { color: "#1565C0", icon: "user" }, + }, + }; + const result = parseDefaultStyling(data); + expect(result).not.toBeNull(); + expect(result?.vertices?.User.color).toBe("#1565C0"); + }); + + it("should return null for invalid input", () => { + const result = parseDefaultStyling({ vertices: "invalid" }); + expect(result).toBeNull(); + }); + + it("should return null for non-object input", () => { + const result = parseDefaultStyling("not an object"); + expect(result).toBeNull(); + }); + + it("should return null for null input", () => { + const result = parseDefaultStyling(null); + expect(result).toBeNull(); + }); +}); + +describe("resolveDefaultStyling", () => { + it("should convert lucide icon name shorthand to a lucide: reference", () => { + const data = { + vertices: { + User: { icon: "user", color: "#1565C0" }, + }, + }; + const result = resolveDefaultStyling(data); + + expect(result.vertices).toHaveLength(1); + expect(result.vertices?.[0].type).toBe(createVertexType("User")); + expect(result.vertices?.[0].iconUrl).toBe("lucide:user"); + expect(result.vertices?.[0].iconImageType).toBe("image/svg+xml"); + expect(result.vertices?.[0].color).toBe("#1565C0"); + }); + + it("should prefer explicit iconUrl over icon shorthand", () => { + const data = { + vertices: { + User: { + icon: "user", + iconUrl: "https://example.com/icon.svg", + iconImageType: "image/svg+xml", + }, + }, + }; + const result = resolveDefaultStyling(data); + + expect(result.vertices?.[0].iconUrl).toBe("https://example.com/icon.svg"); + }); + + it("should pass through unknown lucide names — the render-time resolver handles missing icons", () => { + const data = { + vertices: { + User: { icon: "not-a-real-icon", color: "#1565C0" }, + }, + }; + const result = resolveDefaultStyling(data); + + // Unknown names still produce a lucide ref; render-time decides to draw + // nothing rather than failing the import. + expect(result.vertices?.[0].iconUrl).toBe("lucide:not-a-real-icon"); + expect(result.vertices?.[0].color).toBe("#1565C0"); + }); + + it("should accept full lucide: iconUrl values", () => { + const data = { + vertices: { + User: { + iconUrl: "lucide:plane", + iconImageType: "image/svg+xml", + }, + }, + }; + const result = resolveDefaultStyling(data); + + expect(result.vertices?.[0].iconUrl).toBe("lucide:plane"); + expect(result.vertices?.[0].iconImageType).toBe("image/svg+xml"); + }); + + it("should resolve edge styles", () => { + const data = { + edges: { + OWNS: { lineColor: "#2E7D32", lineThickness: 3 }, + }, + }; + const result = resolveDefaultStyling(data); + + expect(result.edges).toHaveLength(1); + expect(result.edges?.[0].type).toBe(createEdgeType("OWNS")); + expect(result.edges?.[0].lineColor).toBe("#2E7D32"); + expect(result.edges?.[0].lineThickness).toBe(3); + }); + + it("should handle empty data", () => { + const result = resolveDefaultStyling({}); + expect(result.vertices).toHaveLength(0); + expect(result.edges).toHaveLength(0); + }); + + it("should handle multiple vertex types", () => { + const data = { + vertices: { + User: { color: "#1565C0" }, + Account: { color: "#2E7D32" }, + }, + }; + const result = resolveDefaultStyling(data); + + expect(result.vertices).toHaveLength(2); + const types = result.vertices?.map(v => v.type); + expect(types).toContain(createVertexType("User")); + expect(types).toContain(createVertexType("Account")); + }); + + it("should resolve all vertex style properties", () => { + const data = { + vertices: { + User: { + color: "#1565C0", + displayLabel: "Person", + displayNameAttribute: "name", + longDisplayNameAttribute: "fullName", + shape: "round-rectangle" as const, + backgroundOpacity: 0.5, + borderWidth: 2, + borderColor: "#000000", + borderStyle: "dashed" as const, + }, + }, + }; + const result = resolveDefaultStyling(data); + const v = result.vertices?.[0]; + + expect(v?.color).toBe("#1565C0"); + expect(v?.displayLabel).toBe("Person"); + expect(v?.displayNameAttribute).toBe("name"); + expect(v?.longDisplayNameAttribute).toBe("fullName"); + expect(v?.shape).toBe("round-rectangle"); + expect(v?.backgroundOpacity).toBe(0.5); + expect(v?.borderWidth).toBe(2); + expect(v?.borderColor).toBe("#000000"); + expect(v?.borderStyle).toBe("dashed"); + }); + + it("should resolve iconUrl and iconImageType when explicitly provided", () => { + const data = { + vertices: { + User: { + iconUrl: "https://example.com/icon.png", + iconImageType: "image/png", + }, + }, + }; + const result = resolveDefaultStyling(data); + + expect(result.vertices?.[0].iconUrl).toBe("https://example.com/icon.png"); + expect(result.vertices?.[0].iconImageType).toBe("image/png"); + }); + + it("should preserve zero values for backgroundOpacity and borderWidth", () => { + const data = { + vertices: { + User: { backgroundOpacity: 0, borderWidth: 0 }, + }, + }; + const result = resolveDefaultStyling(data); + + expect(result.vertices?.[0].backgroundOpacity).toBe(0); + expect(result.vertices?.[0].borderWidth).toBe(0); + }); +}); + +describe("userStylingToExportFormat", () => { + it("should convert vertex styling to export format", () => { + const styling = { + vertices: [ + { + type: createVertexType("User"), + color: "#1565C0", + iconUrl: "data:image/svg+xml;base64,abc", + }, + ], + }; + const result = userStylingToExportFormat(styling); + + expect(result.vertices).toBeDefined(); + expect(result.vertices?.User).toEqual({ + color: "#1565C0", + iconUrl: "data:image/svg+xml;base64,abc", + }); + }); + + it("should convert edge styling to export format", () => { + const styling = { + edges: [ + { + type: createEdgeType("OWNS"), + lineColor: "#2E7D32", + lineThickness: 3, + }, + ], + }; + const result = userStylingToExportFormat(styling); + + expect(result.edges).toBeDefined(); + expect(result.edges?.OWNS).toEqual({ + lineColor: "#2E7D32", + lineThickness: 3, + }); + }); + + it("should return empty object for empty styling", () => { + const result = userStylingToExportFormat({}); + expect(result).toEqual({}); + }); + + it("should not include type in the exported entry", () => { + const styling = { + vertices: [ + { + type: createVertexType("User"), + color: "#1565C0", + }, + ], + }; + const result = userStylingToExportFormat(styling); + + expect(result.vertices?.User).not.toHaveProperty("type"); + }); +}); diff --git a/packages/graph-explorer/src/core/defaultStyling.ts b/packages/graph-explorer/src/core/defaultStyling.ts new file mode 100644 index 000000000..cd7cd149e --- /dev/null +++ b/packages/graph-explorer/src/core/defaultStyling.ts @@ -0,0 +1,248 @@ +import { z } from "zod"; + +import { logger } from "@/utils"; +import { toLucideIconRef } from "@/utils/lucideIconUrl"; + +import type { + EdgePreferencesStorageModel, + UserStyling, + VertexPreferencesStorageModel, +} from "./StateProvider/userPreferences"; + +import { createEdgeType, createVertexType } from "./entities"; + +/** Zod schema for a single vertex style entry in a styling file. */ +const VertexStyleSchema = z + .object({ + /** + * Shorthand for a Lucide icon name (kebab-case). At parse time this is + * converted to a `lucide:` reference and stored in `iconUrl`. + * Resolution to an SVG data URI happens at render time. + */ + icon: z.string().optional(), + /** + * Full icon reference: `lucide:`, a `data:` URI, or a plain URL. + * Takes precedence over `icon` if both are provided. + */ + iconUrl: z.string().optional(), + /** MIME type for the icon (e.g., "image/svg+xml", "image/png"). */ + iconImageType: z.string().optional(), + /** Hex color for the vertex (e.g., "#1565C0"). */ + color: z.string().optional(), + /** Display label override. */ + displayLabel: z.string().optional(), + /** Which vertex attribute to use as the display name. */ + displayNameAttribute: z.string().optional(), + /** Which vertex attribute to use as the description. */ + longDisplayNameAttribute: z.string().optional(), + /** Node shape. */ + shape: z + .enum([ + "rectangle", + "roundrectangle", + "ellipse", + "triangle", + "pentagon", + "hexagon", + "heptagon", + "octagon", + "star", + "barrel", + "diamond", + "vee", + "rhomboid", + "tag", + "round-rectangle", + "round-triangle", + "round-diamond", + "round-pentagon", + "round-hexagon", + "round-heptagon", + "round-octagon", + "round-tag", + "cut-rectangle", + "concave-hexagon", + ]) + .optional(), + /** Background opacity (0-1). */ + backgroundOpacity: z.number().min(0).max(1).optional(), + /** Border width in pixels. */ + borderWidth: z.number().min(0).optional(), + /** Hex color for the border. */ + borderColor: z.string().optional(), + /** Border line style. */ + borderStyle: z.enum(["solid", "dashed", "dotted"]).optional(), + }) + .strict(); + +/** Zod schema for a single edge style entry in a styling file. */ +const EdgeStyleSchema = z + .object({ + /** Display label override. */ + displayLabel: z.string().optional(), + /** Which edge attribute to use as the display name. */ + displayNameAttribute: z.string().optional(), + /** Hex color for edge label background. */ + labelColor: z.string().optional(), + /** Label background opacity (0-1). */ + labelBackgroundOpacity: z.number().min(0).max(1).optional(), + /** Hex color for label border. */ + labelBorderColor: z.string().optional(), + /** Label border style. */ + labelBorderStyle: z.enum(["solid", "dashed", "dotted"]).optional(), + /** Label border width in pixels. */ + labelBorderWidth: z.number().min(0).optional(), + /** Hex color for the edge line. */ + lineColor: z.string().optional(), + /** Edge line thickness in pixels. */ + lineThickness: z.number().min(0).optional(), + /** Edge line style. */ + lineStyle: z.enum(["solid", "dashed", "dotted"]).optional(), + /** Arrow style at the source end. */ + sourceArrowStyle: z + .enum([ + "triangle", + "triangle-tee", + "circle-triangle", + "triangle-cross", + "triangle-backcurve", + "tee", + "vee", + "square", + "circle", + "diamond", + "none", + ]) + .optional(), + /** Arrow style at the target end. */ + targetArrowStyle: z + .enum([ + "triangle", + "triangle-tee", + "circle-triangle", + "triangle-cross", + "triangle-backcurve", + "tee", + "vee", + "square", + "circle", + "diamond", + "none", + ]) + .optional(), + }) + .strict(); + +/** Zod schema for the complete styling file. */ +export const DefaultStylingSchema = z + .object({ + vertices: z.record(z.string(), VertexStyleSchema).optional(), + edges: z.record(z.string(), EdgeStyleSchema).optional(), + }) + .strict(); + +export type DefaultStylingData = z.infer; + +/** + * Parses and validates a styling file. Returns null if the data is invalid. + */ +export function parseDefaultStyling(data: unknown): DefaultStylingData | null { + const result = DefaultStylingSchema.safeParse(data); + if (!result.success) { + logger.warn("Failed to parse styling data", result.error.flatten()); + return null; + } + return result.data; +} + +/** + * Converts a parsed styling file into a `UserStyling` object suitable for + * the user-styling atom. + * + * - `icon: ` shorthand is converted to `iconUrl: "lucide:"`. + * Resolution to an actual SVG data URI happens at render time. + * - Explicit `iconUrl` values pass through unchanged. + * - The record-of-types format collapses into the array-of-types format. + */ +export function resolveDefaultStyling(data: DefaultStylingData): UserStyling { + const vertices: VertexPreferencesStorageModel[] = []; + const edges: EdgePreferencesStorageModel[] = []; + + if (data.vertices) { + for (const [typeName, style] of Object.entries(data.vertices)) { + const resolved: VertexPreferencesStorageModel = { + type: createVertexType(typeName), + }; + + // Shorthand `icon: ` becomes a `lucide:` reference unless + // an explicit iconUrl is also provided. + if (style.icon && !style.iconUrl) { + resolved.iconUrl = toLucideIconRef(style.icon); + resolved.iconImageType = "image/svg+xml"; + } + + if (style.iconUrl !== undefined) resolved.iconUrl = style.iconUrl; + if (style.iconImageType !== undefined) + resolved.iconImageType = style.iconImageType; + if (style.color !== undefined) resolved.color = style.color; + if (style.displayLabel !== undefined) + resolved.displayLabel = style.displayLabel; + if (style.displayNameAttribute !== undefined) + resolved.displayNameAttribute = style.displayNameAttribute; + if (style.longDisplayNameAttribute !== undefined) + resolved.longDisplayNameAttribute = style.longDisplayNameAttribute; + if (style.shape !== undefined) resolved.shape = style.shape; + if (style.backgroundOpacity !== undefined) + resolved.backgroundOpacity = style.backgroundOpacity; + if (style.borderWidth !== undefined) + resolved.borderWidth = style.borderWidth; + if (style.borderColor !== undefined) + resolved.borderColor = style.borderColor; + if (style.borderStyle !== undefined) + resolved.borderStyle = style.borderStyle; + + vertices.push(resolved); + } + } + + if (data.edges) { + for (const [typeName, style] of Object.entries(data.edges)) { + const resolved: EdgePreferencesStorageModel = { + type: createEdgeType(typeName), + ...style, + }; + edges.push(resolved); + } + } + + return { vertices, edges }; +} + +/** + * Converts the user styling atom back into the file format used by import. + * Round-trips cleanly: `lucide:` references stay symbolic, custom + * uploads stay as data URIs. + */ +export function userStylingToExportFormat( + styling: UserStyling, +): DefaultStylingData { + const result: DefaultStylingData = {}; + + if (styling.vertices?.length) { + result.vertices = {}; + for (const vertex of styling.vertices) { + const { type, ...rest } = vertex; + result.vertices[type] = rest; + } + } + + if (styling.edges?.length) { + result.edges = {}; + for (const edge of styling.edges) { + const { type, ...rest } = edge; + result.edges[type] = rest; + } + } + + return result; +} diff --git a/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx b/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx index 451011b7a..1fd0b7821 100644 --- a/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx +++ b/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx @@ -1,10 +1,17 @@ -import { useAtom } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import localforage from "localforage"; -import { SaveAllIcon } from "lucide-react"; +import { + DownloadIcon, + RotateCcwIcon, + SaveAllIcon, + UploadIcon, +} from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; +import { toast } from "sonner"; import { Button, + FileButton, FormItem, ImportantBlock, Input, @@ -21,12 +28,16 @@ import { allowLoggingDbQueryAtom, defaultNeighborExpansionLimitAtom, defaultNeighborExpansionLimitEnabledAtom, + defaultStylingAtom, diagnosticLoggingAtom, showDebugActionsAtom, + userStylingAtom, } from "@/core"; import { saveLocalForageToFile } from "@/core/StateProvider/localDb"; import LoadConfigButton from "./LoadConfigButton"; +import { useExportStylingFile } from "./useExportStylingFile"; +import { useImportStylingFile } from "./useImportStylingFile"; export default function SettingsGeneral() { const [isDebugOptionsEnabled, setIsDebugOptionsEnabled] = @@ -48,6 +59,22 @@ export default function SettingsGeneral() { setDefaultNeighborExpansionLimitEnabled, ] = useAtom(defaultNeighborExpansionLimitEnabledAtom); + const exportStyling = useExportStylingFile(); + const importStyling = useImportStylingFile(); + const defaultStyling = useAtomValue(defaultStylingAtom); + const setUserStyling = useSetAtom(userStylingAtom); + + function resetAllStyling() { + if (defaultStyling) { + setUserStyling(defaultStyling); + } else { + setUserStyling({}); + } + toast.success("Styling Reset", { + description: "All styling has been reset to defaults", + }); + } + return ( General Settings @@ -111,6 +138,52 @@ export default function SettingsGeneral() {

+ + + + + { + if (file) { + void importStyling(file); + } + }} + > + + Import + + + + + + +

+ Importing styling will replace your current node and edge styling. + Resetting will revert all types to their default appearance. +

+ + ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe("useExportStylingFile", () => { + it("should export current styling as JSON file", async () => { + const saveSpy = vi.spyOn(fileData, "saveFile").mockResolvedValue(undefined); + + const state = new DbState(); + state.activeStyling = { + vertices: [{ type: createVertexType("User"), color: "#1565C0" }], + edges: [{ type: createEdgeType("OWNS"), lineColor: "#2E7D32" }], + }; + + const { result } = renderHookWithState(() => useExportStylingFile(), state); + + await act(async () => { + await result.current(); + }); + + expect(saveSpy).toHaveBeenCalledWith( + expect.any(Blob), + "graph-explorer-styling.json", + ); + + // Verify the blob content + const blob = saveSpy.mock.calls[0][0]; + const text = await blob.text(); + const parsed = JSON.parse(text); + expect(parsed.vertices?.User).toEqual({ color: "#1565C0" }); + expect(parsed.edges?.OWNS).toEqual({ lineColor: "#2E7D32" }); + + saveSpy.mockRestore(); + }); + + it("should export empty styling when no customizations", async () => { + const saveSpy = vi.spyOn(fileData, "saveFile").mockResolvedValue(undefined); + + const state = new DbState(); + state.activeStyling = {}; + const { result } = renderHookWithState(() => useExportStylingFile(), state); + + await act(async () => { + await result.current(); + }); + + expect(saveSpy).toHaveBeenCalled(); + + const blob = saveSpy.mock.calls[0][0]; + const text = await blob.text(); + const parsed = JSON.parse(text); + expect(parsed).toEqual({}); + + saveSpy.mockRestore(); + }); + + it("should show error toast when save fails", async () => { + const saveSpy = vi + .spyOn(fileData, "saveFile") + .mockRejectedValue(new Error("Save failed")); + + const state = new DbState(); + const { result } = renderHookWithState(() => useExportStylingFile(), state); + + await act(async () => { + await result.current(); + }); + + expect(toast.error).toHaveBeenCalledWith( + "Export Failed", + expect.anything(), + ); + + saveSpy.mockRestore(); + }); +}); diff --git a/packages/graph-explorer/src/routes/Settings/useExportStylingFile.ts b/packages/graph-explorer/src/routes/Settings/useExportStylingFile.ts new file mode 100644 index 000000000..e49a7e21e --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/useExportStylingFile.ts @@ -0,0 +1,24 @@ +import { useAtomValue } from "jotai"; +import { toast } from "sonner"; + +import { userStylingToExportFormat } from "@/core/defaultStyling"; +import { userStylingAtom } from "@/core/StateProvider"; +import { logger } from "@/utils"; +import { saveFile, toJsonFileData } from "@/utils/fileData"; + +export function useExportStylingFile() { + const userStyling = useAtomValue(userStylingAtom); + + return async function exportStylingFile() { + try { + const exportData = userStylingToExportFormat(userStyling); + const blob = toJsonFileData(exportData, 2); + await saveFile(blob, "graph-explorer-styling.json"); + } catch (error) { + logger.warn("Failed to export styling file", error); + toast.error("Export Failed", { + description: "Could not save the styling file", + }); + } + }; +} diff --git a/packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx b/packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx new file mode 100644 index 000000000..408087552 --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom +import { act } from "@testing-library/react"; +import { toast } from "sonner"; + +import { defaultStylingAtom, getAppStore, userStylingAtom } from "@/core"; +import { DbState, renderHookWithState } from "@/utils/testing"; + +import { useImportStylingFile } from "./useImportStylingFile"; + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe("useImportStylingFile", () => { + it("should import valid styling file and update default reference", async () => { + const state = new DbState(); + state.activeStyling = {}; + const { result } = renderHookWithState(() => useImportStylingFile(), state); + + const validStyling = { + vertices: { User: { color: "#1565C0" } }, + edges: { OWNS: { lineColor: "#2E7D32" } }, + }; + + const file = new File([JSON.stringify(validStyling)], "styling.json", { + type: "application/json", + }); + + await act(async () => { + await result.current(file); + }); + + const store = getAppStore(); + const styling = store.get(userStylingAtom); + expect(styling.vertices?.find(v => v.type === "User")?.color).toBe( + "#1565C0", + ); + expect(styling.edges?.find(e => e.type === "OWNS")?.lineColor).toBe( + "#2E7D32", + ); + + // defaultStylingAtom should be updated so reset works + const defaults = store.get(defaultStylingAtom); + expect(defaults?.vertices?.find(v => v.type === "User")?.color).toBe( + "#1565C0", + ); + + expect(toast.success).toHaveBeenCalled(); + }); + + it("should show error for invalid styling file", async () => { + const state = new DbState(); + const { result } = renderHookWithState(() => useImportStylingFile(), state); + + const invalidStyling = { vertices: "not-an-object" }; + const file = new File([JSON.stringify(invalidStyling)], "bad.json", { + type: "application/json", + }); + + await act(async () => { + await result.current(file); + }); + + expect(toast.error).toHaveBeenCalledWith("Invalid File", expect.anything()); + }); + + it("should show error for non-JSON file", async () => { + const state = new DbState(); + const { result } = renderHookWithState(() => useImportStylingFile(), state); + + const file = new File(["not json"], "bad.txt", { + type: "text/plain", + }); + + await act(async () => { + await result.current(file); + }); + + expect(toast.error).toHaveBeenCalledWith( + "Import Failed", + expect.anything(), + ); + }); + + it("should convert lucide icon shorthand to lucide: references on import", async () => { + const state = new DbState(); + state.activeStyling = {}; + const { result } = renderHookWithState(() => useImportStylingFile(), state); + + const styling = { + vertices: { User: { icon: "user", color: "#1565C0" } }, + }; + + const file = new File([JSON.stringify(styling)], "styling.json", { + type: "application/json", + }); + + await act(async () => { + await result.current(file); + }); + + const store = getAppStore(); + const imported = store.get(userStylingAtom); + expect(imported.vertices?.find(v => v.type === "User")?.iconUrl).toBe( + "lucide:user", + ); + expect(imported.vertices?.find(v => v.type === "User")?.iconImageType).toBe( + "image/svg+xml", + ); + }); + + it("should import empty styling file", async () => { + const state = new DbState(); + const { result } = renderHookWithState(() => useImportStylingFile(), state); + + const file = new File([JSON.stringify({})], "empty.json", { + type: "application/json", + }); + + await act(async () => { + await result.current(file); + }); + + expect(toast.success).toHaveBeenCalled(); + }); +}); diff --git a/packages/graph-explorer/src/routes/Settings/useImportStylingFile.ts b/packages/graph-explorer/src/routes/Settings/useImportStylingFile.ts new file mode 100644 index 000000000..7bd9fd878 --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/useImportStylingFile.ts @@ -0,0 +1,48 @@ +import { useSetAtom } from "jotai"; +import { toast } from "sonner"; + +import { + parseDefaultStyling, + resolveDefaultStyling, +} from "@/core/defaultStyling"; +import { defaultStylingAtom, userStylingAtom } from "@/core/StateProvider"; +import { logger } from "@/utils"; +import { fromFileToJson } from "@/utils/fileData"; + +export function useImportStylingFile() { + const setUserStyling = useSetAtom(userStylingAtom); + const setDefaultStyling = useSetAtom(defaultStylingAtom); + + return async function importStylingFile(file: File) { + try { + const fileContent = await fromFileToJson(file); + const parsed = parseDefaultStyling(fileContent); + + if (!parsed) { + toast.error("Invalid File", { + description: "The styling file is not valid", + }); + return; + } + + const resolved = resolveDefaultStyling(parsed); + + // Update the reset reference so per-type "Reset to Default" and + // "Reset all styling" restore the imported values. + setDefaultStyling(resolved); + + // Replace user styling with the imported values — an explicit import + // is meant to override existing styling. + setUserStyling(resolved); + + toast.success("Styling Imported", { + description: "Styling has been applied successfully", + }); + } catch (error) { + logger.warn("Failed to import styling file", error); + toast.error("Import Failed", { + description: "Could not read the styling file", + }); + } + }; +} diff --git a/packages/graph-explorer/src/utils/fileData.ts b/packages/graph-explorer/src/utils/fileData.ts index fed9444ce..becb88e34 100644 --- a/packages/graph-explorer/src/utils/fileData.ts +++ b/packages/graph-explorer/src/utils/fileData.ts @@ -1,7 +1,7 @@ import { saveAs } from "file-saver"; -export function toJsonFileData(input: object) { - return new Blob([JSON.stringify(input)], { +export function toJsonFileData(input: object, indent?: number) { + return new Blob([JSON.stringify(input, null, indent)], { type: "application/json", }); } diff --git a/packages/graph-explorer/src/utils/testing/DbState.ts b/packages/graph-explorer/src/utils/testing/DbState.ts index 52feadc3b..0fbdddc68 100644 --- a/packages/graph-explorer/src/utils/testing/DbState.ts +++ b/packages/graph-explorer/src/utils/testing/DbState.ts @@ -5,6 +5,7 @@ import { allGraphSessionsAtom, type AppStore, configurationAtom, + defaultStylingAtom, type Edge, type EdgeId, type EdgePreferencesStorageModel, @@ -15,6 +16,7 @@ import { explorerForTestingAtom, mapEdgeToTypeConfig, mapVertexToTypeConfigs, + mergeDefaultsIntoUserStyling, nodesAtom, nodesFilteredIdsAtom, nodesTypesFilteredAtom, @@ -49,6 +51,7 @@ export class DbState { private _activeSchema: SchemaStorageModel | null; activeConfig: RawConfiguration; activeStyling: UserStyling; + activeDefaultStyling: UserStyling | null = null; explorer: Explorer; @@ -168,6 +171,15 @@ export class DbState { return composedStyle; } + /** + * Sets the default styling (simulates a loaded defaultStyling.json). + * @param styling The default styling to use. + */ + setDefaultStyling(styling: UserStyling) { + this.activeDefaultStyling = styling; + return this; + } + /** * Adds a style configuration for the edge type to the user styling. * @param edgeType The type of the edge to add the style to. @@ -202,8 +214,16 @@ export class DbState { } store.set(activeConfigurationAtom, this.activeConfig.id); - // Styling - store.set(userStylingAtom, this.activeStyling); + // Styling — merge default styling into user styling (mirrors + // AppStatusLoader production behavior). + const mergedStyling = this.activeDefaultStyling + ? mergeDefaultsIntoUserStyling( + this.activeStyling, + this.activeDefaultStyling, + ) + : this.activeStyling; + store.set(userStylingAtom, mergedStyling); + store.set(defaultStylingAtom, this.activeDefaultStyling); // Vertices store.set(nodesAtom, toNodeMap(this.vertices)); diff --git a/vitest.config.ts b/vitest.config.ts index b291732a8..c5da60bec 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ coverage: { thresholds: { autoUpdate: (newThreshold: number) => Math.floor(newThreshold), - statements: 64, + statements: 65, branches: 45, functions: 58, lines: 72,