diff --git a/docs/features/graph-view.md b/docs/features/graph-view.md index 3c850ae90..e6f8c7575 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. - **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/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx b/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx deleted file mode 100644 index 1452b6015..000000000 --- a/packages/graph-explorer/src/utils/useResolvedIconUrl.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -// @vitest-environment happy-dom -import { waitFor } from "@testing-library/react"; - -import { renderHookWithState } from "@/utils/testing"; - -import { useResolvedIconUrl } from "./useResolvedIconUrl"; - -describe("useResolvedIconUrl", () => { - it("returns a plain URL synchronously (no async wait)", () => { - const url = "https://example.com/icon.svg"; - const { result } = renderHookWithState(() => useResolvedIconUrl(url)); - expect(result.current).toBe(url); - }); - - it("returns a data: URI synchronously (no async wait)", () => { - const dataUri = "data:image/svg+xml;base64,PHN2Zy8+"; - const { result } = renderHookWithState(() => useResolvedIconUrl(dataUri)); - expect(result.current).toBe(dataUri); - }); - - it("resolves lucide: to a data URI asynchronously", async () => { - const { result } = renderHookWithState(() => - useResolvedIconUrl("lucide:user"), - ); - - expect(result.current).toBeUndefined(); - - await waitFor(() => { - expect(result.current).toMatch(/^data:image\/svg\+xml;base64,/); - }); - }); - - it("returns undefined for unknown lucide name", async () => { - const { result } = renderHookWithState(() => - useResolvedIconUrl("lucide:not-a-real-icon-name-xyz"), - ); - - await waitFor(() => { - expect(result.current).toBeUndefined(); - }); - }); - - it("returns undefined for an empty iconUrl", () => { - const { result } = renderHookWithState(() => useResolvedIconUrl("")); - expect(result.current).toBeUndefined(); - }); -}); diff --git a/packages/graph-explorer/src/utils/useResolvedIconUrl.ts b/packages/graph-explorer/src/utils/useResolvedIconUrl.ts deleted file mode 100644 index 44c1aab4b..000000000 --- a/packages/graph-explorer/src/utils/useResolvedIconUrl.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { isLucideIconRef, resolveIconUrl } from "./lucideIconUrl"; - -/** - * Resolves a stored `iconUrl` to a value suitable for `` / `` src. - * - * - `lucide:` refs are resolved asynchronously to a base64 SVG data URI - * and cached per session via React Query (`staleTime: Infinity`). - * - `data:` URIs and plain URLs pass through synchronously via `initialData`, - * so consumers can render them immediately on the first paint. - * - Returns `undefined` while a lucide ref is loading on cold-cache. - */ -export function useResolvedIconUrl(iconUrl: string): string | undefined { - const { data } = useQuery({ - queryKey: ["resolved-icon", iconUrl], - queryFn: () => resolveIconUrl(iconUrl), - staleTime: Infinity, - initialData: iconUrl && !isLucideIconRef(iconUrl) ? iconUrl : undefined, - }); - return data ?? undefined; -} 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, },