From 89910a219f8b26c6d01357bf6bcad5de0d2e2237 Mon Sep 17 00:00:00 2001 From: jkemmererupgrade Date: Mon, 9 Mar 2026 21:33:22 -0600 Subject: [PATCH 1/2] Add defaultStyling.json support for persistent per-type vertex and edge styling Adds an optional defaultStyling.json file that can be mounted into the Docker container to provide per-type vertex and edge styling defaults. This addresses the need for persistent styling in environments where browser storage is cleared between sessions (e.g., AWS WorkSpaces Web). On startup, file values are merged into the user's per-type styling preferences (IndexedDB). Default values fill in properties the user hasn't explicitly set; existing user overrides are preserved. The file also serves as a reference for per-type "Reset to Default" in the Node/Edge Style dialogs. The Settings page adds Export/Import/Reset All styling controls: - Export: saves current styling as defaultStyling.json for sharing or Docker-mounting as team defaults - Import: merges a defaultStyling.json into user styling and updates the reset reference so reset-after-import restores imported values - Reset All: reverts all styling to defaults (from file or hardcoded) Supports lucide icon names (resolved to SVG data URIs at runtime via dynamic imports), custom icon URLs, colors, shapes, border styles, edge line styles, and arrow styles. Includes Zod validation with strict type checking for shapes and arrow styles. Includes 58 new tests with 100% line coverage on new modules, example file, and documentation. Co-Authored-By: Claude Opus 4.6 --- Changelog.md | 5 + docs/references/README.md | 1 + docs/references/default-styling.md | 163 +++++++ example/defaultStyling.json | 86 ++++ .../src/node-server.ts | 8 + .../src/core/AppStatusLoader.tsx | 46 +- .../core/StateProvider/configuration.test.ts | 31 ++ .../src/core/StateProvider/configuration.ts | 4 +- .../core/StateProvider/defaultStylingAtom.ts | 15 + .../src/core/StateProvider/index.ts | 1 + .../StateProvider/userPreferences.test.ts | 167 +++++++ .../src/core/StateProvider/userPreferences.ts | 63 ++- .../src/core/defaultStyling.test.ts | 419 ++++++++++++++++++ .../graph-explorer/src/core/defaultStyling.ts | 284 ++++++++++++ .../src/routes/Settings/SettingsGeneral.tsx | 78 +++- .../Settings/useExportStylingFile.test.tsx | 88 ++++ .../routes/Settings/useExportStylingFile.ts | 25 ++ .../Settings/useImportStylingFile.test.tsx | 125 ++++++ .../routes/Settings/useImportStylingFile.ts | 52 +++ .../src/utils/lucideIconUrl.test.ts | 80 ++++ .../graph-explorer/src/utils/lucideIconUrl.ts | 91 ++++ .../src/utils/testing/DbState.ts | 24 +- 22 files changed, 1844 insertions(+), 12 deletions(-) create mode 100644 docs/references/default-styling.md create mode 100644 example/defaultStyling.json 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 create mode 100644 packages/graph-explorer/src/utils/lucideIconUrl.test.ts create mode 100644 packages/graph-explorer/src/utils/lucideIconUrl.ts diff --git a/Changelog.md b/Changelog.md index f0652c10b..dbb4b4dfa 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,10 @@ # Graph Explorer Change Log +## Unreleased + +- Add defaultStyling.json support for persistent per-type vertex and edge + styling (#1265, #112, #173, #573, #689) + ## Release 3.0.0 Graph Explorer 3.0 is here! This release brings one of the most requested features — the ability to visualize your graph database schema — along with a fresh navigation experience and a handful of quality-of-life improvements. diff --git a/docs/references/README.md b/docs/references/README.md index a4a773451..eb940c51e 100644 --- a/docs/references/README.md +++ b/docs/references/README.md @@ -7,3 +7,4 @@ Technical reference documentation for Graph Explorer covering configuration, sec - [Health Check](./health-check.md) - Proxy server health and readiness endpoint - [Logging](./logging.md) - Log levels, output, and proxy server logging modules - [Default Connection](./default-connection.md) - Configure a default connection via environment variables or JSON +- [Default Styling](./default-styling.md) - Configure default vertex and edge styling via a JSON file diff --git a/docs/references/default-styling.md b/docs/references/default-styling.md new file mode 100644 index 000000000..0775e1c36 --- /dev/null +++ b/docs/references/default-styling.md @@ -0,0 +1,163 @@ +# Default Styling Configuration + +Graph Explorer supports an optional `defaultStyling.json` file that provides +default vertex and edge styling for all users. This is useful for: + +- Non-persistent browser environments (e.g., AWS WorkSpaces Web) where IndexedDB + is cleared between sessions +- Pre-configuring a shared visual style for teams +- Providing a consistent starting point for new users + +## How It Works + +On startup, Graph Explorer fetches `defaultStyling.json` and merges its values +into the user's per-type styling preferences (stored in IndexedDB). Default +values fill in any properties the user hasn't explicitly set — existing user +overrides are preserved. + +When no `defaultStyling.json` is mounted, behavior is identical to the default +Graph Explorer experience. When mounted, it populates the per-type styling that +users can then customize. Resetting a vertex or edge style in the UI will revert +to the `defaultStyling.json` values (or the hardcoded application defaults if no +entry exists for that type). + +## Setup + +### Docker + +Mount the file into the container's configuration folder: + +```bash +docker run \ + -v /path/to/defaultStyling.json:/graph-explorer/packages/graph-explorer/defaultStyling.json \ + public.ecr.aws/neptune/graph-explorer +``` + +### Custom Icons + +To serve custom icon files (referenced by URL in the config), mount an icons +directory: + +```bash +docker run \ + -v /path/to/defaultStyling.json:/graph-explorer/packages/graph-explorer/defaultStyling.json \ + -v /path/to/icons:/graph-explorer/packages/graph-explorer/custom-icons \ + public.ecr.aws/neptune/graph-explorer +``` + +Icons in the `custom-icons` directory are served at `/custom-icons/`. + +## JSON Schema + +```json +{ + "vertices": { + "": { + "color": "#hex", + "icon": "lucide-icon-name", + "iconUrl": "url-or-base64", + "iconImageType": "image/svg+xml", + "shape": "ellipse", + "displayLabel": "Custom Label", + "displayNameAttribute": "name", + "longDisplayNameAttribute": "description", + "backgroundOpacity": 0.4, + "borderWidth": 0, + "borderColor": "#hex", + "borderStyle": "solid" + } + }, + "edges": { + "": { + "displayLabel": "Custom Label", + "displayNameAttribute": "name", + "labelColor": "#hex", + "labelBackgroundOpacity": 0.7, + "labelBorderColor": "#hex", + "labelBorderStyle": "solid", + "labelBorderWidth": 0, + "lineColor": "#hex", + "lineThickness": 2, + "lineStyle": "solid", + "sourceArrowStyle": "none", + "targetArrowStyle": "triangle" + } + } +} +``` + +All properties are optional — only specify what you want to override. Type +labels must exactly match the vertex/edge type names in your graph database. + +### Icons + +There are two ways to specify vertex icons: + +- **`icon`** — A [Lucide](https://lucide.dev/icons) icon name in kebab-case + (e.g., `"user"`, `"log-in"`, `"landmark"`). Resolved to an SVG at runtime. No + additional files needed. +- **`iconUrl`** — A URL or base64 data URI for a custom icon. Use this for + non-Lucide icons. If both `icon` and `iconUrl` are specified, `iconUrl` takes + precedence. + +### Vertex Shapes + +Available shapes: `ellipse`, `rectangle`, `diamond`, `triangle`, `pentagon`, +`hexagon`, `heptagon`, `octagon`, `star`, `barrel`, `vee`, `rhomboid`, `tag`, +`round-rectangle`, `round-triangle`, `round-diamond`, `round-pentagon`, +`round-hexagon`, `round-heptagon`, `round-octagon`, `round-tag`, +`cut-rectangle`, `concave-hexagon`. + +### Line Styles + +Available for edges and borders: `solid`, `dashed`, `dotted`. + +### Arrow Styles + +Available for `sourceArrowStyle` and `targetArrowStyle`: `triangle`, +`triangle-tee`, `circle-triangle`, `triangle-cross`, `triangle-backcurve`, +`tee`, `vee`, `square`, `circle`, `diamond`, `none`. + +## Common Lucide Icons for Graph Use Cases + +| Use Case | Icon Name | +| -------------------- | --------------------------------- | +| Person / User | `user` | +| Account / Bank | `landmark` | +| Email | `mail` | +| Phone | `phone` | +| Login / Auth | `log-in` | +| Device | `monitor`, `laptop`, `smartphone` | +| IP Address / Network | `globe`, `network` | +| Transaction | `arrow-left-right` | +| Location | `map-pin` | +| Organization | `building` | +| Alert | `shield-alert`, `triangle-alert` | +| Document | `file-text` | +| Calendar / Date | `calendar` | +| Lock / Security | `lock`, `shield` | +| Database | `database` | +| Server | `server` | +| Link / Relationship | `link` | + +See the full list at [lucide.dev/icons](https://lucide.dev/icons). + +## Import / Export / Reset + +The Settings page provides styling management: + +- **Export Styling** — exports the current per-type styling as a + `defaultStyling.json` file, for sharing or Docker-mounting as team defaults. +- **Import Styling** — imports a `defaultStyling.json` file. This is an + alternative to mounting the file in Docker. +- **Reset All Styling** — resets all styling to defaults. If a + `defaultStyling.json` is mounted, those values are restored; otherwise, + styling reverts to the application defaults. + +Per-type reset is also available in the Node/Edge Style dialogs via the "Reset +to Default" button. + +## Example + +See [`example/defaultStyling.json`](../example/defaultStyling.json) for a +complete example with banking-oriented vertex and edge types. diff --git a/example/defaultStyling.json b/example/defaultStyling.json new file mode 100644 index 000000000..122a8856b --- /dev/null +++ b/example/defaultStyling.json @@ -0,0 +1,86 @@ +{ + "vertices": { + "User": { + "color": "#1565C0", + "icon": "user", + "shape": "ellipse" + }, + "Account": { + "color": "#2E7D32", + "icon": "landmark", + "shape": "ellipse" + }, + "Login": { + "color": "#EF6C00", + "icon": "log-in", + "shape": "ellipse" + }, + "EmailAddress": { + "color": "#C62828", + "icon": "mail", + "shape": "ellipse" + }, + "PhoneNumber": { + "color": "#6A1B9A", + "icon": "phone", + "shape": "ellipse" + }, + "Device": { + "color": "#00838F", + "icon": "monitor", + "shape": "rectangle" + }, + "IPAddress": { + "color": "#4E342E", + "icon": "globe", + "shape": "diamond" + }, + "Transaction": { + "color": "#1B5E20", + "icon": "arrow-left-right", + "shape": "ellipse" + }, + "Location": { + "color": "#E65100", + "icon": "map-pin", + "shape": "ellipse" + }, + "Organization": { + "color": "#283593", + "icon": "building", + "shape": "rectangle" + } + }, + "edges": { + "OWNS": { + "lineColor": "#2E7D32", + "lineThickness": 2, + "lineStyle": "solid" + }, + "TRANSFERRED_TO": { + "lineColor": "#1B5E20", + "lineThickness": 3, + "lineStyle": "solid" + }, + "HAS_EMAIL": { + "lineColor": "#9E9E9E", + "lineThickness": 1, + "lineStyle": "dashed" + }, + "HAS_PHONE": { + "lineColor": "#9E9E9E", + "lineThickness": 1, + "lineStyle": "dashed" + }, + "LOGGED_IN_FROM": { + "lineColor": "#EF6C00", + "lineThickness": 2, + "lineStyle": "solid" + }, + "LOCATED_AT": { + "lineColor": "#E65100", + "lineThickness": 1, + "lineStyle": "dotted" + } + } +} diff --git a/packages/graph-explorer-proxy-server/src/node-server.ts b/packages/graph-explorer-proxy-server/src/node-server.ts index 8d96bcd78..68cfc8016 100644 --- a/packages/graph-explorer-proxy-server/src/node-server.ts +++ b/packages/graph-explorer-proxy-server/src/node-server.ts @@ -180,6 +180,14 @@ app.use( path.join(defaultConnectionFolderPath, "defaultConnection.json"), ), ); +app.use( + "/defaultStyling", + express.static(path.join(defaultConnectionFolderPath, "defaultStyling.json")), +); +app.use( + "/custom-icons", + express.static(path.join(defaultConnectionFolderPath, "custom-icons")), +); // Host the Graph Explorer UI static files const staticFilesVirtualPath = "/explorer"; diff --git a/packages/graph-explorer/src/core/AppStatusLoader.tsx b/packages/graph-explorer/src/core/AppStatusLoader.tsx index 36535788f..a100d356c 100644 --- a/packages/graph-explorer/src/core/AppStatusLoader.tsx +++ b/packages/graph-explorer/src/core/AppStatusLoader.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useAtom } from "jotai"; +import { useAtom, useSetAtom } from "jotai"; import { type PropsWithChildren, startTransition, @@ -11,7 +11,14 @@ import { PanelEmptyState, Spinner } from "@/components"; import { logger } from "@/utils"; import { fetchDefaultConnection } from "./defaultConnection"; -import { activeConfigurationAtom, configurationAtom } from "./StateProvider"; +import { fetchDefaultStyling, resolveDefaultStyling } from "./defaultStyling"; +import { + activeConfigurationAtom, + configurationAtom, + defaultStylingAtom, + mergeDefaultsIntoUserStyling, + userStylingAtom, +} from "./StateProvider"; function AppStatusLoader({ children }: PropsWithChildren) { return ( @@ -24,6 +31,8 @@ function AppStatusLoader({ children }: PropsWithChildren) { function LoadDefaultConfig({ children }: PropsWithChildren) { const [activeConfig, setActiveConfig] = useAtom(activeConfigurationAtom); const [configuration, setConfiguration] = useAtom(configurationAtom); + const setDefaultStyling = useSetAtom(defaultStylingAtom); + const setUserStyling = useSetAtom(userStylingAtom); const defaultConfigQuery = useQuery({ queryKey: ["default-connection"], @@ -33,6 +42,39 @@ function LoadDefaultConfig({ children }: PropsWithChildren) { enabled: configuration.size === 0, }); + // Fetch default styling on every session start + const defaultStylingQuery = useQuery({ + queryKey: ["default-styling"], + queryFn: fetchDefaultStyling, + staleTime: Infinity, + }); + + useEffect(() => { + const data = defaultStylingQuery.data; + if (!data) { + return; + } + let cancelled = false; + logger.debug("Applying default styling", data); + resolveDefaultStyling(data) + .then(resolved => { + if (!cancelled) { + // Store reference copy for per-type "Reset to Default" + setDefaultStyling(resolved); + + // Merge file values into user styling. Default values fill in + // properties the user hasn't explicitly set; user overrides win. + setUserStyling(prev => mergeDefaultsIntoUserStyling(prev, resolved)); + } + }) + .catch(err => { + logger.warn("Failed to resolve default styling", err); + }); + return () => { + cancelled = true; + }; + }, [defaultStylingQuery.data, setDefaultStyling, setUserStyling]); + const defaultConnectionConfigs = defaultConfigQuery.data; useEffect(() => { diff --git a/packages/graph-explorer/src/core/StateProvider/configuration.test.ts b/packages/graph-explorer/src/core/StateProvider/configuration.test.ts index a972d7cdb..f948c723f 100644 --- a/packages/graph-explorer/src/core/StateProvider/configuration.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/configuration.test.ts @@ -257,6 +257,37 @@ 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); + if (style) { + expect(v.color).toBe("#FF0000"); + } + } + for (const e of result.schema?.edges ?? []) { + const style = styling.edges?.find(s => s.type === e.type); + if (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/configuration.ts b/packages/graph-explorer/src/core/StateProvider/configuration.ts index f3119f406..87e01ef43 100644 --- a/packages/graph-explorer/src/core/StateProvider/configuration.ts +++ b/packages/graph-explorer/src/core/StateProvider/configuration.ts @@ -170,7 +170,7 @@ const mergeVertex = ( ...(patchedSchema || {}), // File-based override ...(patchedConfig || {}), - // User preferences override + // User preferences override (includes defaults from defaultStyling.json) ...(preferences || {}), attributes, }; @@ -204,7 +204,7 @@ const mergeEdge = ( ...(patchedSchema || {}), // File-based override ...(patchedConfig || {}), - // User preferences override + // User preferences override (includes defaults from defaultStyling.json) ...(preferences || {}), attributes, }; 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 786eda58d..b4d5ce3b4 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 00ba721d6..9b3190479 100644 --- a/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts @@ -7,6 +7,7 @@ import { defaultEdgePreferences, defaultVertexPreferences, type EdgePreferencesStorageModel, + mergeDefaultsIntoUserStyling, useEdgeStyling, useVertexStyling, type VertexPreferencesStorageModel, @@ -339,6 +340,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 54e386bdf..7104b036d 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 default styling from defaultStyling.json into user styling. + * Default values fill in properties the user hasn't explicitly set; + * existing user overrides win via spread order. + */ +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 }; +} + /** Get the stored user preferences for vertices and edges in a fast lookup Map. */ function useStoredGraphPreferences() { const graphPreferences = useAtomValue(userStylingAtom); @@ -170,7 +203,10 @@ function useStoredGraphPreferences() { return deferredResult; } -/** Combines the stored user preferences with the defined default values. */ +/** + * Combines hardcoded defaults with user preferences. + * User preferences include values populated from defaultStyling.json on load. + */ export function createVertexPreference( type: VertexType, stored?: VertexPreferencesStorageModel, @@ -182,7 +218,10 @@ export function createVertexPreference( } as const; } -/** Combines the stored user preferences with the defined default values. */ +/** + * Combines hardcoded defaults with user preferences. + * User preferences include values populated from defaultStyling.json on load. + */ export function createEdgePreference( type: EdgeType, stored?: EdgePreferencesStorageModel, @@ -256,6 +295,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) => @@ -279,9 +319,17 @@ export function useVertexStyling(type: VertexType) { const resetVertexStyle = () => setAllStyling(prev => { + // Restore from defaultStyling.json if available, otherwise remove entirely + // (which falls back to 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, }; }); @@ -302,6 +350,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) => @@ -325,9 +374,15 @@ export function useEdgeStyling(type: EdgeType) { const resetEdgeStyle = () => setAllStyling(prev => { + // Restore from defaultStyling.json if available, otherwise remove entirely + // (which falls back to 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..ef4a58845 --- /dev/null +++ b/packages/graph-explorer/src/core/defaultStyling.test.ts @@ -0,0 +1,419 @@ +import { + DefaultStylingSchema, + fetchDefaultStyling, + 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); + expect(result.success).toBe(true); + if (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("fetchDefaultStyling", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should return parsed data on successful fetch", async () => { + const mockData = { vertices: { User: { color: "#1565C0" } } }; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(mockData), { status: 200 }), + ); + const result = await fetchDefaultStyling(); + expect(result).not.toBeNull(); + expect(result?.vertices?.User.color).toBe("#1565C0"); + }); + + it("should return null on 404", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Not Found", { status: 404 }), + ); + const result = await fetchDefaultStyling(); + expect(result).toBeNull(); + }); + + it("should return null on non-404 error status", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Server Error", { status: 500 }), + ); + const result = await fetchDefaultStyling(); + expect(result).toBeNull(); + }); + + it("should return null on fetch exception", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error")); + const result = await fetchDefaultStyling(); + expect(result).toBeNull(); + }); + + it("should return null for invalid JSON response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response('{"vertices": "invalid"}', { status: 200 }), + ); + const result = await fetchDefaultStyling(); + expect(result).toBeNull(); + }); +}); + +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 resolve lucide icon names to data URIs", async () => { + const data = { + vertices: { + User: { icon: "user", color: "#1565C0" }, + }, + }; + const result = await resolveDefaultStyling(data); + + expect(result.vertices).toHaveLength(1); + expect(result.vertices?.[0].type).toBe(createVertexType("User")); + expect(result.vertices?.[0].iconUrl).toMatch( + /^data:image\/svg\+xml;base64,/, + ); + expect(result.vertices?.[0].iconImageType).toBe("image/svg+xml"); + expect(result.vertices?.[0].color).toBe("#1565C0"); + }); + + it("should prefer explicit iconUrl over lucide icon name", async () => { + const data = { + vertices: { + User: { + icon: "user", + iconUrl: "https://example.com/icon.svg", + iconImageType: "image/svg+xml", + }, + }, + }; + const result = await resolveDefaultStyling(data); + + expect(result.vertices?.[0].iconUrl).toBe("https://example.com/icon.svg"); + }); + + it("should handle unknown lucide icon names gracefully", async () => { + const data = { + vertices: { + User: { icon: "not-a-real-icon", color: "#1565C0" }, + }, + }; + const result = await resolveDefaultStyling(data); + + expect(result.vertices?.[0].iconUrl).toBeUndefined(); + expect(result.vertices?.[0].color).toBe("#1565C0"); + }); + + it("should resolve edge styles", async () => { + const data = { + edges: { + OWNS: { lineColor: "#2E7D32", lineThickness: 3 }, + }, + }; + const result = await 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", async () => { + const result = await resolveDefaultStyling({}); + expect(result.vertices).toHaveLength(0); + expect(result.edges).toHaveLength(0); + }); + + it("should handle multiple vertex types", async () => { + const data = { + vertices: { + User: { color: "#1565C0" }, + Account: { color: "#2E7D32" }, + }, + }; + const result = await 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", async () => { + 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 = await 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", async () => { + const data = { + vertices: { + User: { + iconUrl: "https://example.com/icon.png", + iconImageType: "image/png", + }, + }, + }; + const result = await 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", async () => { + const data = { + vertices: { + User: { backgroundOpacity: 0, borderWidth: 0 }, + }, + }; + const result = await 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..22489623a --- /dev/null +++ b/packages/graph-explorer/src/core/defaultStyling.ts @@ -0,0 +1,284 @@ +import { z } from "zod"; + +import { logger } from "@/utils"; +import { lucideIconToDataUri } 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 defaultStyling.json. */ +const VertexStyleSchema = z + .object({ + /** Lucide icon name (kebab-case). Resolved to an SVG data URI at runtime. */ + icon: z.string().optional(), + /** Custom icon URL or base64 data URI. Takes precedence over `icon`. */ + iconUrl: z.string().optional(), + /** MIME type for custom 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 defaultStyling.json. */ +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 defaultStyling.json 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; + +/** + * Fetches the default styling configuration from the server. + * Returns null if no file is mounted (404) or if the data is invalid. + */ +export async function fetchDefaultStyling(): Promise { + const defaultStylingPath = `${location.origin}/defaultStyling`; + + try { + logger.debug("Fetching default styling from", defaultStylingPath); + const response = await fetch(defaultStylingPath); + + if (!response.ok) { + if (response.status === 404) { + logger.debug("No default styling file found"); + } else { + logger.warn( + `Response status ${response.status} for default styling`, + await response.text(), + ); + } + return null; + } + + const data = await response.json(); + return parseDefaultStyling(data); + } catch (error) { + logger.warn("Failed to fetch default styling", error); + return null; + } +} + +/** + * Parses and validates default styling data from any source (server fetch or file import). + * 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 default styling data", result.error.flatten()); + return null; + } + + return result.data; +} + +/** + * Resolves a DefaultStylingData object into a UserStyling object. + * + * This converts lucide icon names to data URIs and maps the record-based + * format (keyed by type name) into the array-based format used by UserStyling. + */ +export async function resolveDefaultStyling( + data: DefaultStylingData, +): Promise { + const vertices: VertexPreferencesStorageModel[] = []; + const edges: EdgePreferencesStorageModel[] = []; + + if (data.vertices) { + for (const [typeName, style] of Object.entries(data.vertices)) { + const resolved: VertexPreferencesStorageModel = { + type: createVertexType(typeName), + }; + + // Resolve lucide icon name to data URI if no explicit iconUrl is provided + if (style.icon && !style.iconUrl) { + const dataUri = await lucideIconToDataUri(style.icon); + if (dataUri) { + resolved.iconUrl = dataUri; + resolved.iconImageType = "image/svg+xml"; + } else { + logger.warn( + `Unknown lucide icon name "${style.icon}" for vertex type "${typeName}"`, + ); + } + } + + // Copy over all other defined properties (using !== undefined to + // preserve valid falsy values like 0 for borderWidth) + 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 a UserStyling object back to the DefaultStylingData format + * suitable for export to a JSON file. + * + * Note: Lucide icon names cannot be reverse-resolved from data URIs, + * so exported files will contain iconUrl data URIs instead of icon names. + */ +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 78b47951d..7c7239662 100644 --- a/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx +++ b/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx @@ -1,10 +1,18 @@ -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 { useCallback } from "react"; +import { toast } from "sonner"; import { Button, + FileButton, FormItem, ImportantBlock, Input, @@ -21,11 +29,15 @@ import { allowLoggingDbQueryAtom, defaultNeighborExpansionLimitAtom, defaultNeighborExpansionLimitEnabledAtom, + defaultStylingAtom, 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] = @@ -43,6 +55,22 @@ export default function SettingsGeneral() { setDefaultNeighborExpansionLimitEnabled, ] = useAtom(defaultNeighborExpansionLimitEnabledAtom); + const exportStyling = useExportStylingFile(); + const importStyling = useImportStylingFile(); + const defaultStyling = useAtomValue(defaultStylingAtom); + const setUserStyling = useSetAtom(userStylingAtom); + + const resetAllStyling = useCallback(() => { + if (defaultStyling) { + setUserStyling(defaultStyling); + } else { + setUserStyling({}); + } + toast.success("Styling Reset", { + description: "All styling has been reset to defaults", + }); + }, [defaultStyling, setUserStyling]); + return ( General Settings @@ -106,6 +134,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), + "defaultStyling.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..d8151a5dc --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/useExportStylingFile.ts @@ -0,0 +1,25 @@ +import { useAtomValue } from "jotai"; +import { useCallback } from "react"; +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 useCallback(async () => { + try { + const exportData = userStylingToExportFormat(userStyling); + const blob = toJsonFileData(exportData); + await saveFile(blob, "defaultStyling.json"); + } catch (error) { + logger.warn("Failed to export styling file", error); + toast.error("Export Failed", { + description: "Could not save the styling file", + }); + } + }, [userStyling]); +} 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..463897de9 --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx @@ -0,0 +1,125 @@ +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 resolve lucide icon names during 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).toMatch( + /^data:image\/svg\+xml;base64,/, + ); + }); + + 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..c79a1fefe --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/useImportStylingFile.ts @@ -0,0 +1,52 @@ +import { useSetAtom } from "jotai"; +import { useCallback } from "react"; +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 useCallback( + async (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 = await resolveDefaultStyling(parsed); + + // Update the reset reference so "Reset to Default" uses the + // imported values + setDefaultStyling(resolved); + + // Replace user styling with the imported values — an explicit import + // should 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", + }); + } + }, + [setUserStyling, setDefaultStyling], + ); +} 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..1020e9362 --- /dev/null +++ b/packages/graph-explorer/src/utils/lucideIconUrl.test.ts @@ -0,0 +1,80 @@ +import type dynamicIconImports from "lucide-react/dynamicIconImports"; + +import { lucideIconToDataUri } 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="); + }); +}); diff --git a/packages/graph-explorer/src/utils/lucideIconUrl.ts b/packages/graph-explorer/src/utils/lucideIconUrl.ts new file mode 100644 index 000000000..4feef4f22 --- /dev/null +++ b/packages/graph-explorer/src/utils/lucideIconUrl.ts @@ -0,0 +1,91 @@ +import dynamicIconImports from "lucide-react/dynamicIconImports"; + +type IconNodeChild = [string, Record]; + +interface LucideIconModule { + __iconNode?: IconNodeChild[]; + default?: unknown; +} + +/** + * 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 iconNode = await getLucideIconNode(iconName); + if (!iconNode) { + return null; + } + const svgString = buildSvgString(iconNode); + 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/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)); From c16b6d7b316a74521e155b3ebe21a7e221803f19 Mon Sep 17 00:00:00 2001 From: jkemmererupgrade Date: Thu, 12 Mar 2026 10:08:05 -0600 Subject: [PATCH 2/2] Add Lucide icon picker to node styling dialog Adds a searchable icon browser popover alongside the existing Upload button in the node style dialog. Users can browse ~1,900 Lucide icons with search filtering, capped at 50 visible results for performance. Icons are lazy-loaded using the existing lucideIconToDataUri helper. Co-Authored-By: Claude Opus 4.6 --- Changelog.md | 1 + docs/references/default-styling.md | 4 +- .../src/components/IconPicker.test.tsx | 117 +++++++++++++++ .../src/components/IconPicker.tsx | 135 ++++++++++++++++++ .../graph-explorer/src/components/index.ts | 1 + .../modules/NodesStyling/NodeStyleDialog.tsx | 6 + 6 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 packages/graph-explorer/src/components/IconPicker.test.tsx create mode 100644 packages/graph-explorer/src/components/IconPicker.tsx diff --git a/Changelog.md b/Changelog.md index dbb4b4dfa..e9b9a5916 100644 --- a/Changelog.md +++ b/Changelog.md @@ -4,6 +4,7 @@ - Add defaultStyling.json support for persistent per-type vertex and edge styling (#1265, #112, #173, #573, #689) +- Add Lucide icon picker to node styling dialog ## Release 3.0.0 diff --git a/docs/references/default-styling.md b/docs/references/default-styling.md index 0775e1c36..ebcce86b8 100644 --- a/docs/references/default-styling.md +++ b/docs/references/default-styling.md @@ -91,8 +91,10 @@ labels must exactly match the vertex/edge type names in your graph database. ### Icons -There are two ways to specify vertex icons: +There are three ways to set vertex icons: +- **Icon Picker (UI)** — In the Node Style dialog, click **Browse** to search + and select from the full Lucide icon library (~1,900 icons). - **`icon`** — A [Lucide](https://lucide.dev/icons) icon name in kebab-case (e.g., `"user"`, `"log-in"`, `"landmark"`). Resolved to an SVG at runtime. No additional files needed. 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..e78314887 --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.test.tsx @@ -0,0 +1,117 @@ +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 data URI when icon is clicked", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + // Wait for icons to load then click the first one + 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(onSelect).toHaveBeenCalledWith( + expect.stringMatching(/^data:image\/svg\+xml;base64,/), + "image/svg+xml", + ); + }); + }); + + 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..f8b6f852c --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.tsx @@ -0,0 +1,135 @@ +import { SearchIcon } from "lucide-react"; +import dynamicIconImports from "lucide-react/dynamicIconImports"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { lucideIconToDataUri } from "@/utils/lucideIconUrl"; + +import { Button, Input, Popover, PopoverContent, PopoverTrigger } from "."; + +const allIconNames = Object.keys(dynamicIconImports).sort(); + +const MAX_VISIBLE = 50; + +export function IconPicker({ + onSelect, +}: { + onSelect: (iconUrl: string, iconImageType: string) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const inputRef = useRef(null); + + const filtered = useMemo(() => { + 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; + }, [search]); + + const handleSelect = useCallback( + async (iconName: string) => { + const dataUri = await lucideIconToDataUri(iconName); + if (dataUri) { + onSelect(dataUri, "image/svg+xml"); + setOpen(false); + setSearch(""); + } + }, + [onSelect], + ); + + // Focus search input when popover opens + useEffect(() => { + if (open) { + // Small delay to allow popover animation + 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, + onSelect, +}: { + name: string; + 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 ( + + ); +} diff --git a/packages/graph-explorer/src/components/index.ts b/packages/graph-explorer/src/components/index.ts index 2bc9878eb..4343b7aec 100644 --- a/packages/graph-explorer/src/components/index.ts +++ b/packages/graph-explorer/src/components/index.ts @@ -36,6 +36,7 @@ export * from "./Form"; export * from "./numberFormat"; export * from "./icons"; +export * from "./IconPicker"; export * from "./Input"; export { default as InputField } from "./InputField"; diff --git a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx index 4dd74e54e..905a14764 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,11 @@ function Content({ vertexType }: { vertexType: VertexType }) { Icon
+ + setVertexStyle({ iconUrl, iconImageType }) + } + /> {