Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/features/graph-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions docs/features/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
@@ -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<UserStyling | null>(null);
1 change: 1 addition & 0 deletions packages/graph-explorer/src/core/StateProvider/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./appStore";
export * from "./configuration";
export * from "./defaultStylingAtom";
export * from "./displayAttribute";
export * from "./displayEdge";
export * from "./displayTypeConfigs";
Expand Down
167 changes: 167 additions & 0 deletions packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
defaultVertexPreferences,
edgePreferencesAtom,
type EdgePreferencesStorageModel,
mergeDefaultsIntoUserStyling,
useEdgeStyling,
useVertexStyling,
vertexPreferencesAtom,
Expand Down Expand Up @@ -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();
Expand Down
53 changes: 51 additions & 2 deletions packages/graph-explorer/src/core/StateProvider/userPreferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -158,6 +159,38 @@ export type UserStyling = {
edges?: Array<EdgePreferencesStorageModel>;
};

/**
* 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);
Expand Down Expand Up @@ -260,6 +293,7 @@ type UpdatedVertexStyle = Partial<Omit<VertexPreferences, "type">>;
*/
export function useVertexStyling(type: VertexType) {
const setAllStyling = useSetAtom(userStylingAtom);
const defaultStyling = useAtomValue(defaultStylingAtom);
const vertexStyle = useVertexPreferences(type);

const setVertexStyle = (updatedStyle: UpdatedVertexStyle) =>
Expand All @@ -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,
};
});

Expand All @@ -306,6 +348,7 @@ type UpdatedEdgeStyle = Omit<EdgePreferencesStorageModel, "type">;
*/
export function useEdgeStyling(type: EdgeType) {
const setAllStyling = useSetAtom(userStylingAtom);
const defaultStyling = useAtomValue(defaultStylingAtom);
const edgeStyle = useEdgePreferences(type);

const setEdgeStyle = (updatedStyle: UpdatedEdgeStyle) =>
Expand All @@ -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,
};
});

Expand Down
Loading