diff --git a/docs/features/graph-view.md b/docs/features/graph-view.md index e6f8c7575..965549349 100644 --- a/docs/features/graph-view.md +++ b/docs/features/graph-view.md @@ -83,6 +83,7 @@ Each node type can be customized in a variety of ways. - **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. +- **Conditional Style** — a single condition can be defined per node type via the **Conditional Style** tab in the styling dialog. When a node's property matches the condition (`property operator value`, e.g. `known_bad = true`), a secondary style (any combination of color, icon, border, shape, opacity, and display properties) overrides the base style in the graph canvas and all previews. The condition and its override are persisted, exported, and imported alongside the base style. ### Edge Styling Panel @@ -93,6 +94,7 @@ Each edge type can be customized in a variety of ways. - **Arrow symbol** can be chosen for both source and target variations - **Colors and borders** can be customized for the edge label and the line - **Line style** can be solid, dotted, or dashed +- **Conditional Style** — same as node conditional style above, but applied to edge line color, thickness, style, arrow shapes, and label appearance. ### Namespace Panel diff --git a/packages/graph-explorer/src/core/StateProvider/conditionalStyling.test.ts b/packages/graph-explorer/src/core/StateProvider/conditionalStyling.test.ts new file mode 100644 index 000000000..ecb309806 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/conditionalStyling.test.ts @@ -0,0 +1,86 @@ +import { + buildConditionSelector, + buildConditionalNodeSelector, + buildConditionalEdgeSelector, + validOperatorsForDataType, +} from "./conditionalStyling"; + +describe("buildConditionSelector", () => { + it("quotes string values", () => { + expect( + buildConditionSelector( + { property: "identifier_type", operator: "=", value: "SSN" }, + "String", + ), + ).toBe('[prop_identifier_type = "SSN"]'); + }); + + it("quotes boolean values (stored as strings)", () => { + expect( + buildConditionSelector( + { property: "known_bad", operator: "=", value: "true" }, + "Boolean", + ), + ).toBe('[prop_known_bad = "true"]'); + }); + + it("does not quote numeric values", () => { + expect( + buildConditionSelector( + { property: "score", operator: ">", value: "90" }, + "Number", + ), + ).toBe("[prop_score > 90]"); + }); + + it("quotes values when dataType is undefined", () => { + expect( + buildConditionSelector( + { property: "x", operator: "=", value: "y" }, + undefined, + ), + ).toBe('[prop_x = "y"]'); + }); +}); + +describe("buildConditionalNodeSelector", () => { + it("combines type + condition", () => { + expect( + buildConditionalNodeSelector( + "Customer", + { property: "known_bad", operator: "=", value: "true" }, + "Boolean", + ), + ).toBe('node[type="Customer"][prop_known_bad = "true"]'); + }); +}); + +describe("buildConditionalEdgeSelector", () => { + it("builds edge selector", () => { + expect( + buildConditionalEdgeSelector( + "OWNS", + { property: "active", operator: "=", value: "false" }, + "Boolean", + ), + ).toBe('edge[type="OWNS"][prop_active = "false"]'); + }); +}); + +describe("validOperatorsForDataType", () => { + it("returns all 6 for Number", () => { + expect(validOperatorsForDataType("Number")).toHaveLength(6); + }); + + it("returns only = and != for String", () => { + expect(validOperatorsForDataType("String")).toEqual(["=", "!="]); + }); + + it("returns only = and != for Boolean", () => { + expect(validOperatorsForDataType("Boolean")).toEqual(["=", "!="]); + }); + + it("returns all 6 for Date", () => { + expect(validOperatorsForDataType("Date")).toHaveLength(6); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/conditionalStyling.ts b/packages/graph-explorer/src/core/StateProvider/conditionalStyling.ts new file mode 100644 index 000000000..de8448213 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/conditionalStyling.ts @@ -0,0 +1,68 @@ +export type ConditionOperator = "=" | "!=" | ">" | "<" | ">=" | "<="; + +/** + * A single condition that triggers the secondary style. + * `value` is always stored as a string; coercion to the right + * Cytoscape literal happens at selector-build time. + */ +export type StyleCondition = { + property: string; + operator: ConditionOperator; + value: string; +}; + +/** + * Source-property key prefix added to Cytoscape element data so that + * conditions can reference them without colliding with built-in keys + * (type, id, displayName, …). + */ +export const PROP_PREFIX = "prop_"; + +/** + * Builds the Cytoscape attribute selector fragment for one condition. + * + * String/Boolean properties use quoted values: `[prop_x = "true"]` + * Number properties use unquoted values: `[prop_score > 90]` + */ +export function buildConditionSelector( + condition: StyleCondition, + dataType: string | undefined, +): string { + const key = `${PROP_PREFIX}${condition.property}`; + const isNumeric = dataType === "Number"; + const literal = isNumeric ? condition.value : `"${condition.value}"`; + return `[${key} ${condition.operator} ${literal}]`; +} + +/** + * Full Cytoscape selector for a vertex type + condition. + * e.g. `node[type="Customer"][prop_known_bad = "true"]` + */ +export function buildConditionalNodeSelector( + vertexType: string, + condition: StyleCondition, + dataType: string | undefined, +): string { + return `node[type="${vertexType}"]${buildConditionSelector(condition, dataType)}`; +} + +export function buildConditionalEdgeSelector( + edgeType: string, + condition: StyleCondition, + dataType: string | undefined, +): string { + return `edge[type="${edgeType}"]${buildConditionSelector(condition, dataType)}`; +} + +/** + * Returns operators valid for the given dataType. + * Non-numeric types only support equality operators. + */ +export function validOperatorsForDataType( + dataType: string | undefined, +): ConditionOperator[] { + if (dataType === "Number" || dataType === "Date") { + return ["=", "!=", ">", "<", ">=", "<="]; + } + return ["=", "!="]; +} diff --git a/packages/graph-explorer/src/core/StateProvider/renderedEntities.test.ts b/packages/graph-explorer/src/core/StateProvider/renderedEntities.test.ts index 6e9c90e6b..7be576b8e 100644 --- a/packages/graph-explorer/src/core/StateProvider/renderedEntities.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/renderedEntities.test.ts @@ -22,6 +22,7 @@ import { type RenderedEdgeId, type RenderedVertexId, useRenderedEntities, + useRenderedVertices, } from "./renderedEntities"; describe("createRenderedVertexId", () => { @@ -137,6 +138,25 @@ describe("useRenderedVertices", () => { expect(vertexIds).toStrictEqual([vertex3.id]); }); }); + + it("exposes vertex attributes as prop_ prefixed keys in Cytoscape data", async () => { + const vertex = createTestableVertex().with({ + attributes: { known_bad: true, score: 42 }, + }); + const dbState = new DbState(); + dbState.addTestableVertexToGraph(vertex); + + const { result } = renderHookWithJotai( + () => useRenderedVertices(), + store => dbState.applyTo(store), + ); + + await waitFor(() => { + const element = result.current.find(v => v.data.vertexId === vertex.id); + expect(element?.data["prop_known_bad"]).toBe(true); + expect(element?.data["prop_score"]).toBe(42); + }); + }); }); describe("useRenderedEdges", () => { diff --git a/packages/graph-explorer/src/core/StateProvider/renderedEntities.ts b/packages/graph-explorer/src/core/StateProvider/renderedEntities.ts index d08814f5e..45da63ba6 100644 --- a/packages/graph-explorer/src/core/StateProvider/renderedEntities.ts +++ b/packages/graph-explorer/src/core/StateProvider/renderedEntities.ts @@ -7,6 +7,7 @@ import { type DisplayVertex, edgesFilteredIdsAtom, edgesTypesFilteredAtom, + type EntityPropertyValue, type EntityRawId, nodesFilteredIdsAtom, nodesTypesFilteredAtom, @@ -18,14 +19,31 @@ import { import type { EdgeId } from "../entities/edge"; +import { PROP_PREFIX } from "./conditionalStyling"; + /** A string representation of a vertex ID that encodes the original type. Cytoscape requires IDs to be strings. */ export type RenderedVertexId = Branded; /** A string representation of an edge ID that encodes the original type. Cytoscape requires IDs to be strings. */ export type RenderedEdgeId = Branded; +/** + * The data payload stored in a Cytoscape vertex element. + * The index signature allows conditional-styling selectors to access + * source vertex attributes via `prop_*` prefixed keys. + */ +export type RenderedVertexData = { + id: RenderedVertexId; + type: string; + vertexId: VertexId; + displayName: string; + displayTypes: string; + neighborCount: number; + [key: string]: EntityPropertyValue | RenderedVertexId | VertexId; +}; + /** A representation of a vertex that Cytoscape can use. */ -export type RenderedVertex = ReturnType; +export type RenderedVertex = { data: RenderedVertexData }; /** A representation of an edge that Cytoscape can use. */ export type RenderedEdge = ReturnType; @@ -166,17 +184,24 @@ function stripIdTypePrefix(id: string): string { * - The `id` property is a string * - There exists a `data` property where any custom data is stored */ -function createRenderedVertex(vertex: DisplayVertex, neighborCount: number) { - return { - data: { - id: createRenderedVertexId(vertex.id), - type: vertex.primaryType, - vertexId: vertex.id, - displayName: vertex.displayName, - displayTypes: vertex.displayTypes, - neighborCount, - }, +function createRenderedVertex( + vertex: DisplayVertex, + neighborCount: number, +): RenderedVertex { + const data: RenderedVertexData = { + id: createRenderedVertexId(vertex.id), + type: vertex.primaryType, + vertexId: vertex.id, + displayName: vertex.displayName, + displayTypes: vertex.displayTypes, + neighborCount, }; + for (const [k, v] of Object.entries(vertex.original.attributes)) { + // Cytoscape selectors can't compare against Date objects — coerce to ISO string + // so date conditions like `create_date > "2026-01-01"` work lexicographically. + data[`${PROP_PREFIX}${k}`] = v instanceof Date ? v.toISOString() : v; + } + return { data }; } /** diff --git a/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts index 8bec96347..d09c03f3f 100644 --- a/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts @@ -2,9 +2,10 @@ import { useAtomValue } from "jotai"; import { act } from "react"; -import { createEdgeType, createVertexType } from "@/core"; +import { createEdgeType, createVertexType, getAppStore } from "@/core"; import { DbState, renderHookWithState } from "@/utils/testing"; +import { userStylingAtom } from "./storageAtoms"; import { defaultEdgePreferences, defaultVertexPreferences, @@ -450,6 +451,157 @@ describe("default styling", () => { expect(result.current.edgeStyle.lineColor).toBe("red"); expect(result.current.edgeStyle.lineThickness).toBe(5); }); + + it("should save and retrieve a conditional style", () => { + const dbState = new DbState(); + const { result } = renderHookWithState( + () => useVertexStyling(createVertexType("test")), + dbState, + ); + + act(() => + result.current.setConditionalVertexStyle( + { property: "known_bad", operator: "=", value: "true" }, + { color: "#FF0000" }, + ), + ); + + const store = getAppStore(); + const entry = store + .get(userStylingAtom) + .vertices?.find(v => v.type === "test"); + expect(entry?.condition?.property).toBe("known_bad"); + expect(entry?.conditionalStyle?.color).toBe("#FF0000"); + }); + + it("should remove a conditional style", () => { + const dbState = new DbState(); + dbState.addVertexStyle(createVertexType("test"), { + color: "#0000FF", + condition: { property: "known_bad", operator: "=", value: "true" }, + conditionalStyle: { color: "#FF0000" }, + } as any); + + const { result } = renderHookWithState( + () => useVertexStyling(createVertexType("test")), + dbState, + ); + + act(() => result.current.removeConditionalVertexStyle()); + + const store = getAppStore(); + const entry = store + .get(userStylingAtom) + .vertices?.find(v => v.type === "test"); + expect(entry?.condition).toBeUndefined(); + expect(entry?.conditionalStyle).toBeUndefined(); + }); + + it("should clear conditional style on reset", () => { + const dbState = new DbState(); + dbState.addVertexStyle(createVertexType("test"), { + color: "#0000FF", + condition: { property: "known_bad", operator: "=", value: "true" }, + conditionalStyle: { color: "#FF0000" }, + } as any); + + const { result } = renderHookWithState( + () => useVertexStyling(createVertexType("test")), + dbState, + ); + + act(() => result.current.resetVertexStyle()); + + const store = getAppStore(); + const entry = store + .get(userStylingAtom) + .vertices?.find(v => v.type === "test"); + expect(entry?.condition).toBeUndefined(); + expect(entry?.conditionalStyle).toBeUndefined(); + }); + + it("should save and retrieve a conditional edge style", () => { + const dbState = new DbState(); + const { result } = renderHookWithState( + () => useEdgeStyling(createEdgeType("test")), + dbState, + ); + + act(() => + result.current.setConditionalEdgeStyle( + { property: "active", operator: "=", value: "false" }, + { lineColor: "#FF0000" }, + ), + ); + + const store = getAppStore(); + const entry = store + .get(userStylingAtom) + .edges?.find(e => e.type === "test"); + expect(entry?.condition?.property).toBe("active"); + expect(entry?.conditionalStyle?.lineColor).toBe("#FF0000"); + }); + + it("should save conditional edge style when no prior entry exists", () => { + const dbState = new DbState(); + const { result } = renderHookWithState( + () => useEdgeStyling(createEdgeType("newtype")), + dbState, + ); + + act(() => + result.current.setConditionalEdgeStyle( + { property: "weight", operator: ">", value: "5" }, + { lineThickness: 4 }, + ), + ); + + const store = getAppStore(); + const entry = store + .get(userStylingAtom) + .edges?.find(e => e.type === "newtype"); + expect(entry?.condition?.property).toBe("weight"); + expect(entry?.conditionalStyle?.lineThickness).toBe(4); + }); + + it("should remove a conditional edge style", () => { + const dbState = new DbState(); + dbState.addEdgeStyle(createEdgeType("test"), { + lineColor: "#0000FF", + condition: { property: "active", operator: "=", value: "false" }, + conditionalStyle: { lineColor: "#FF0000" }, + } as any); + + const { result } = renderHookWithState( + () => useEdgeStyling(createEdgeType("test")), + dbState, + ); + + act(() => result.current.removeConditionalEdgeStyle()); + + const store = getAppStore(); + const entry = store + .get(userStylingAtom) + .edges?.find(e => e.type === "test"); + expect(entry?.condition).toBeUndefined(); + expect(entry?.conditionalStyle).toBeUndefined(); + }); + + it("should be a no-op when removing conditional edge style that does not exist", () => { + const dbState = new DbState(); + const { result } = renderHookWithState( + () => useEdgeStyling(createEdgeType("test")), + dbState, + ); + + const store = getAppStore(); + const before = store.get(userStylingAtom); + + act(() => result.current.removeConditionalEdgeStyle()); + + const after = store.get(userStylingAtom); + expect(after).toStrictEqual(before); + }); }); describe("mergeDefaultsIntoUserStyling", () => { diff --git a/packages/graph-explorer/src/core/StateProvider/userPreferences.ts b/packages/graph-explorer/src/core/StateProvider/userPreferences.ts index 7832800df..efee2ec66 100644 --- a/packages/graph-explorer/src/core/StateProvider/userPreferences.ts +++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.ts @@ -8,6 +8,7 @@ import { RESERVED_ID_PROPERTY, RESERVED_TYPES_PROPERTY } from "@/utils"; import DEFAULT_ICON_URL from "@/utils/defaultIconUrl"; import type { EdgeType, VertexType } from "../entities"; +import type { StyleCondition } from "./conditionalStyling"; import { defaultStylingAtom } from "./defaultStylingAtom"; import { useActiveSchema } from "./schema"; @@ -84,6 +85,13 @@ export type VertexPreferencesStorageModel = { borderWidth?: number; borderColor?: string; borderStyle?: LineStyle; + /** Optional single condition that activates the secondary style. */ + condition?: StyleCondition; + /** Style overrides applied when `condition` is met. All fields optional. */ + conditionalStyle?: Omit< + VertexPreferencesStorageModel, + "type" | "condition" | "conditionalStyle" + >; }; /** The user preferences to be used for the specified edge type as the type used for storing in local storage. */ @@ -101,13 +109,23 @@ export type EdgePreferencesStorageModel = { lineStyle?: LineStyle; sourceArrowStyle?: ArrowStyle; targetArrowStyle?: ArrowStyle; + condition?: StyleCondition; + conditionalStyle?: Omit< + EdgePreferencesStorageModel, + "type" | "condition" | "conditionalStyle" + >; }; /** The user preferences to be used for the specified vertex type as an immutable object. */ export type VertexPreferences = Simplify< Readonly< Pick & - Required> + Required< + Omit< + VertexPreferencesStorageModel, + "displayLabel" | "condition" | "conditionalStyle" + > + > > >; @@ -115,7 +133,12 @@ export type VertexPreferences = Simplify< export type EdgePreferences = Simplify< Readonly< Pick & - Required> + Required< + Omit< + EdgePreferencesStorageModel, + "displayLabel" | "condition" | "conditionalStyle" + > + > > >; @@ -323,18 +346,67 @@ export function useVertexStyling(type: VertexType) { v => v.type === type, ); const withoutCurrent = prev.vertices?.filter(v => v.type !== type) ?? []; + const resetEntry = defaultForType + ? { + ...defaultForType, + condition: undefined, + conditionalStyle: undefined, + } + : undefined; return { ...prev, - vertices: defaultForType - ? [...withoutCurrent, defaultForType] - : withoutCurrent, + vertices: resetEntry ? [...withoutCurrent, resetEntry] : withoutCurrent, }; }); + function setConditionalVertexStyle( + condition: StyleCondition, + style: Omit< + VertexPreferencesStorageModel, + "type" | "condition" | "conditionalStyle" + >, + ) { + setAllStyling(prev => { + const vertices = prev.vertices ?? []; + const existingIndex = vertices.findIndex(v => v.type === type); + if (existingIndex >= 0) { + const updated = [...vertices]; + updated[existingIndex] = { + ...vertices[existingIndex], + condition, + conditionalStyle: style, + }; + return { ...prev, vertices: updated }; + } + return { + ...prev, + vertices: [...vertices, { type, condition, conditionalStyle: style }], + }; + }); + } + + function removeConditionalVertexStyle() { + setAllStyling(prev => { + const vertices = prev.vertices ?? []; + const existingIndex = vertices.findIndex(v => v.type === type); + if (existingIndex < 0) return prev; + const updated = [...vertices]; + const { + condition: _c, + conditionalStyle: _cs, + ...rest + } = vertices[existingIndex]; + updated[existingIndex] = rest; + return { ...prev, vertices: updated }; + }); + } + return { vertexStyle, setVertexStyle, resetVertexStyle, + setConditionalVertexStyle, + removeConditionalVertexStyle, }; } @@ -376,17 +448,66 @@ export function useEdgeStyling(type: EdgeType) { // 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) ?? []; + const resetEntry = defaultForType + ? { + ...defaultForType, + condition: undefined, + conditionalStyle: undefined, + } + : undefined; + return { + ...prev, + edges: resetEntry ? [...withoutCurrent, resetEntry] : withoutCurrent, + }; + }); + + function setConditionalEdgeStyle( + condition: StyleCondition, + style: Omit< + EdgePreferencesStorageModel, + "type" | "condition" | "conditionalStyle" + >, + ) { + setAllStyling(prev => { + const edges = prev.edges ?? []; + const existingIndex = edges.findIndex(e => e.type === type); + if (existingIndex >= 0) { + const updated = [...edges]; + updated[existingIndex] = { + ...edges[existingIndex], + condition, + conditionalStyle: style, + }; + return { ...prev, edges: updated }; + } return { ...prev, - edges: defaultForType - ? [...withoutCurrent, defaultForType] - : withoutCurrent, + edges: [...edges, { type, condition, conditionalStyle: style }], }; }); + } + + function removeConditionalEdgeStyle() { + setAllStyling(prev => { + const edges = prev.edges ?? []; + const existingIndex = edges.findIndex(e => e.type === type); + if (existingIndex < 0) return prev; + const updated = [...edges]; + const { + condition: _c, + conditionalStyle: _cs, + ...rest + } = edges[existingIndex]; + updated[existingIndex] = rest; + return { ...prev, edges: updated }; + }); + } return { edgeStyle, setEdgeStyle, resetEdgeStyle, + setConditionalEdgeStyle, + removeConditionalEdgeStyle, }; } diff --git a/packages/graph-explorer/src/core/defaultStyling.test.ts b/packages/graph-explorer/src/core/defaultStyling.test.ts index 926b136c5..963c70e1c 100644 --- a/packages/graph-explorer/src/core/defaultStyling.test.ts +++ b/packages/graph-explorer/src/core/defaultStyling.test.ts @@ -384,3 +384,79 @@ describe("userStylingToExportFormat", () => { expect(result.vertices?.User).not.toHaveProperty("type"); }); }); + +describe("conditional styling in schema", () => { + it("accepts a vertex with a condition and conditionalStyle", () => { + const data = { + vertices: { + Customer: { + color: "#0D47A1", + condition: { + property: "known_bad", + operator: "=" as const, + value: "true", + }, + conditionalStyle: { color: "#D32F2F", icon: "alert-triangle" }, + }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("rejects an invalid operator", () => { + const data = { + vertices: { + Customer: { + condition: { property: "x", operator: "===", value: "y" }, + }, + }, + }; + const result = DefaultStylingSchema.safeParse(data); + expect(result.success).toBe(false); + }); +}); + +describe("resolveDefaultStyling with conditions", () => { + it("propagates condition and converts icon shorthand in conditionalStyle", () => { + const data = { + vertices: { + Customer: { + condition: { + property: "known_bad", + operator: "=" as const, + value: "true", + }, + conditionalStyle: { icon: "alert-triangle", color: "#D32F2F" }, + }, + }, + }; + const result = resolveDefaultStyling(data); + const v = result.vertices?.[0]; + expect(v?.condition?.property).toBe("known_bad"); + expect(v?.conditionalStyle?.iconUrl).toBe("lucide:alert-triangle"); + expect(v?.conditionalStyle?.color).toBe("#D32F2F"); + }); + + it("round-trips through userStylingToExportFormat", () => { + const styling = { + vertices: [ + { + type: createVertexType("Customer"), + color: "#0D47A1", + condition: { + property: "known_bad", + operator: "=" as const, + value: "true", + }, + conditionalStyle: { color: "#D32F2F" }, + }, + ], + }; + const exported = userStylingToExportFormat(styling); + expect(exported.vertices?.Customer?.condition?.property).toBe("known_bad"); + expect(exported.vertices?.Customer?.conditionalStyle?.color).toBe( + "#D32F2F", + ); + }); +}); diff --git a/packages/graph-explorer/src/core/defaultStyling.ts b/packages/graph-explorer/src/core/defaultStyling.ts index cd7cd149e..6e0a2d181 100644 --- a/packages/graph-explorer/src/core/defaultStyling.ts +++ b/packages/graph-explorer/src/core/defaultStyling.ts @@ -11,6 +11,57 @@ import type { import { createEdgeType, createVertexType } from "./entities"; +/** Named enum values for vertex shape, shared by base style and conditionalStyle. */ +const ShapeEnumValues = 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", +]); + +/** Named enum values for edge arrow style, shared by base style and conditionalStyle. */ +const ArrowStyleEnumValues = z.enum([ + "triangle", + "triangle-tee", + "circle-triangle", + "triangle-cross", + "triangle-backcurve", + "tee", + "vee", + "square", + "circle", + "diamond", + "none", +]); + +const ConditionOperatorSchema = z.enum(["=", "!=", ">", "<", ">=", "<="]); + +const StyleConditionSchema = z.object({ + property: z.string(), + operator: ConditionOperatorSchema, + value: z.string(), +}); + /** Zod schema for a single vertex style entry in a styling file. */ const VertexStyleSchema = z .object({ @@ -36,34 +87,7 @@ const VertexStyleSchema = z /** 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(), + shape: ShapeEnumValues.optional(), /** Background opacity (0-1). */ backgroundOpacity: z.number().min(0).max(1).optional(), /** Border width in pixels. */ @@ -72,6 +96,26 @@ const VertexStyleSchema = z borderColor: z.string().optional(), /** Border line style. */ borderStyle: z.enum(["solid", "dashed", "dotted"]).optional(), + /** Optional single condition that activates the secondary style. */ + condition: StyleConditionSchema.optional(), + /** Style overrides applied when condition is met. All fields optional. */ + conditionalStyle: z + .object({ + icon: z.string().optional(), + iconUrl: z.string().optional(), + iconImageType: z.string().optional(), + color: z.string().optional(), + displayLabel: z.string().optional(), + displayNameAttribute: z.string().optional(), + longDisplayNameAttribute: z.string().optional(), + shape: ShapeEnumValues.optional(), + backgroundOpacity: z.number().min(0).max(1).optional(), + borderWidth: z.number().min(0).optional(), + borderColor: z.string().optional(), + borderStyle: z.enum(["solid", "dashed", "dotted"]).optional(), + }) + .strict() + .optional(), }) .strict(); @@ -99,36 +143,28 @@ const EdgeStyleSchema = z /** 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(), + sourceArrowStyle: ArrowStyleEnumValues.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", - ]) + targetArrowStyle: ArrowStyleEnumValues.optional(), + /** Optional single condition that activates the secondary style. */ + condition: StyleConditionSchema.optional(), + /** Style overrides applied when condition is met. All fields optional. */ + conditionalStyle: z + .object({ + displayLabel: z.string().optional(), + displayNameAttribute: z.string().optional(), + labelColor: z.string().optional(), + labelBackgroundOpacity: z.number().min(0).max(1).optional(), + labelBorderColor: z.string().optional(), + labelBorderStyle: z.enum(["solid", "dashed", "dotted"]).optional(), + labelBorderWidth: z.number().min(0).optional(), + lineColor: z.string().optional(), + lineThickness: z.number().min(0).optional(), + lineStyle: z.enum(["solid", "dashed", "dotted"]).optional(), + sourceArrowStyle: ArrowStyleEnumValues.optional(), + targetArrowStyle: ArrowStyleEnumValues.optional(), + }) + .strict() .optional(), }) .strict(); @@ -201,6 +237,19 @@ export function resolveDefaultStyling(data: DefaultStylingData): UserStyling { if (style.borderStyle !== undefined) resolved.borderStyle = style.borderStyle; + if (style.condition) { + resolved.condition = style.condition; + } + if (style.conditionalStyle) { + const cs = { ...style.conditionalStyle }; + if (cs.icon && !cs.iconUrl) { + cs.iconUrl = toLucideIconRef(cs.icon); + cs.iconImageType = "image/svg+xml"; + } + delete (cs as Record).icon; + resolved.conditionalStyle = cs; + } + vertices.push(resolved); } } @@ -211,6 +260,9 @@ export function resolveDefaultStyling(data: DefaultStylingData): UserStyling { type: createEdgeType(typeName), ...style, }; + if (style.condition) resolved.condition = style.condition; + if (style.conditionalStyle) + resolved.conditionalStyle = { ...style.conditionalStyle }; edges.push(resolved); } } diff --git a/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx b/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx index 1be478523..26d853ade 100644 --- a/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx @@ -1,4 +1,5 @@ import { atom, useAtom, useSetAtom } from "jotai"; +import { useState } from "react"; import { Button, @@ -32,12 +33,14 @@ import { } from "@/core"; import { type ArrowStyle, + type EdgePreferencesStorageModel, type LineStyle, useEdgeStyling, } from "@/core/StateProvider/userPreferences"; import useTranslations from "@/hooks/useTranslations"; import { parseNumberSafely, RESERVED_TYPES_PROPERTY } from "@/utils"; +import { ConditionalSection } from "../NodesStyling/ConditionalSection"; import { ARROW_STYLE_OPTIONS } from "./arrowsStyling"; import { LINE_STYLE_OPTIONS } from "./lineStyling"; @@ -75,7 +78,25 @@ function Content({ edgeType }: { edgeType: EdgeType }) { const t = useTranslations(); const queryEngine = useQueryEngine(); - const { edgeStyle, setEdgeStyle, resetEdgeStyle } = useEdgeStyling(edgeType); + const [activePane, setActivePane] = useState<"base" | "conditional">("base"); + + const { + edgeStyle, + setEdgeStyle, + resetEdgeStyle, + setConditionalEdgeStyle, + removeConditionalEdgeStyle, + } = useEdgeStyling(edgeType); + + // Access condition and conditionalStyle from the raw storage model. + // createEdgePreference spreads the full storage model at runtime even + // though EdgePreferences omits these fields from its type. + const rawStyle = edgeStyle as unknown as Pick< + EdgePreferencesStorageModel, + "condition" | "conditionalStyle" + >; + + const attributes = displayConfig.attributes; // In SPARQL there are no edge attributes, so predicate is the only and default option const hideDisplayNameAttribute = queryEngine === "sparql"; @@ -103,211 +124,509 @@ function Content({ edgeType }: { edgeType: EdgeType }) { -
- {hideDisplayNameAttribute ? null : ( - - - Display Name {t("property")} - - - - )} +
+ + +
+ {activePane === "base" && (
- Label Styling - - - Background Color - setEdgeStyle({ labelColor: color })} - /> - - - Background Opacity - - setEdgeStyle({ - labelBackgroundOpacity: parseNumberSafely( - e.target.value, - ), - }) - } - /> - - - - - Border Color - - setEdgeStyle({ labelBorderColor: color }) - } - /> - - - Border Width - - setEdgeStyle({ - labelBorderWidth: parseNumberSafely(e.target.value), - }) - } - /> - - - Border Style - + setEdgeStyle({ displayNameAttribute: value }) + } + > + + + + + {selectOptions.map(option => ( + {option.label} - {option.icon} - - - ))} - - - - + + ))} + + + + + )} + +
+ Label Styling + + + Background Color + + setEdgeStyle({ labelColor: color }) + } + /> + + + Background Opacity + + setEdgeStyle({ + labelBackgroundOpacity: parseNumberSafely( + e.target.value, + ), + }) + } + /> + + + + + Border Color + + setEdgeStyle({ labelBorderColor: color }) + } + /> + + + Border Width + + setEdgeStyle({ + labelBorderWidth: parseNumberSafely(e.target.value), + }) + } + /> + + + Border Style + + + +
+
+ Line Styling + + + Line Color + + setEdgeStyle({ lineColor: color }) + } + /> + + + + Line Thickness + + setEdgeStyle({ + lineThickness: parseNumberSafely(e.target.value), + }) + } + /> + + + Line Style + + + + + + Source Arrow Style + + + + Target Arrow Style + + + +
-
- Line Styling - - - Line Color - setEdgeStyle({ lineColor: color })} - /> - + )} - - Line Thickness - - setEdgeStyle({ - lineThickness: parseNumberSafely(e.target.value), - }) - } - /> - - - Line Style - - - - - - Source Arrow Style - - - - Target Arrow Style - { + if (!rawStyle.condition) return; + setConditionalEdgeStyle(rawStyle.condition, { + ...rawStyle.conditionalStyle, + displayNameAttribute: value, + }); + }} + > + + + + + {selectOptions.map(option => ( + {option.label} - - - - ))} - - - - + + ))} + + + + + )} + +
+ Label Styling + + + Background Color + { + if (!rawStyle.condition) return; + setConditionalEdgeStyle(rawStyle.condition, { + ...rawStyle.conditionalStyle, + labelColor: color, + }); + }} + /> + + + Background Opacity + { + if (!rawStyle.condition) return; + setConditionalEdgeStyle(rawStyle.condition, { + ...rawStyle.conditionalStyle, + labelBackgroundOpacity: parseNumberSafely( + e.target.value, + ), + }); + }} + /> + + + + + Border Color + { + if (!rawStyle.condition) return; + setConditionalEdgeStyle(rawStyle.condition, { + ...rawStyle.conditionalStyle, + labelBorderColor: color, + }); + }} + /> + + + Border Width + { + if (!rawStyle.condition) return; + setConditionalEdgeStyle(rawStyle.condition, { + ...rawStyle.conditionalStyle, + labelBorderWidth: parseNumberSafely(e.target.value), + }); + }} + /> + + + Border Style + + + +
+ +
+ Line Styling + + + Line Color + { + if (!rawStyle.condition) return; + setConditionalEdgeStyle(rawStyle.condition, { + ...rawStyle.conditionalStyle, + lineColor: color, + }); + }} + /> + + + Line Thickness + { + if (!rawStyle.condition) return; + setConditionalEdgeStyle(rawStyle.condition, { + ...rawStyle.conditionalStyle, + lineThickness: parseNumberSafely(e.target.value), + }); + }} + /> + + + Line Style + + + + + + Source Arrow Style + + + + Target Arrow Style + + + +
+ + {rawStyle.condition && ( + + )}
-
+ )}
- + {activePane === "base" && ( + + )} diff --git a/packages/graph-explorer/src/modules/GraphViewer/useBackgroundImageMap.ts b/packages/graph-explorer/src/modules/GraphViewer/useBackgroundImageMap.ts index b0cf17996..8005e3d35 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useBackgroundImageMap.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/useBackgroundImageMap.ts @@ -1,31 +1,72 @@ -import { useQueries } from "@tanstack/react-query"; +import { type QueryClient, useQueries } from "@tanstack/react-query"; -import type { VertexPreferences } from "@/core"; +import type { VertexPreferences, VertexTypeConfig } from "@/core"; -import { renderNode } from "./renderNode"; +import { type VertexIconConfig, renderNode } from "./renderNode"; + +type IconQuery = { + queryKey: unknown[]; + queryFn: (ctx: { client: QueryClient }) => Promise<{ + backgroundImage: string | null; + key: string; + }>; +}; /** * Generates appropriate background images from vertex type configurations, * considering the image type and applying colors for SVG icons. * + * Base entries are keyed by vertex type name. + * Conditional icon entries (when a type has `condition` + `conditionalStyle.iconUrl`) + * are keyed by `${type}:cond`. + * * @param vtConfigs - Array of vertex type configurations containing styling and * display information - * @returns A Map where keys are vertex type names and values are their - * corresponding background image strings + * @param vtTypeConfigs - Optional map of full vertex type configs (includes + * condition/conditionalStyle fields) used to generate conditional icon entries + * @returns A Map where keys are vertex type names (or `type:cond` for + * conditional entries) and values are their corresponding background image strings */ -export function useBackgroundImageMap(vtConfigs: VertexPreferences[]) { +export function useBackgroundImageMap( + vtConfigs: VertexPreferences[], + vtTypeConfigs?: Map, +) { + const baseQueries: IconQuery[] = vtConfigs.map(vtConfig => ({ + queryKey: ["vertexIcon", vtConfig], + queryFn: async ({ client }: { client: QueryClient }) => { + const backgroundImage = await renderNode(client, vtConfig); + return { backgroundImage, key: vtConfig.type as string }; + }, + })); + + const conditionalQueries: IconQuery[] = []; + if (vtTypeConfigs) { + for (const vtTypeConfig of vtTypeConfigs.values()) { + const condStyle = vtTypeConfig.conditionalStyle; + if (!vtTypeConfig.condition || !condStyle?.iconUrl) continue; + const condIconConfig: VertexIconConfig = { + type: vtTypeConfig.type, + iconUrl: condStyle.iconUrl, + iconImageType: condStyle.iconImageType ?? vtTypeConfig.iconImageType, + color: condStyle.color ?? vtTypeConfig.color, + }; + const condKey = `${vtTypeConfig.type}:cond`; + conditionalQueries.push({ + queryKey: ["vertexIconCond", condIconConfig], + queryFn: async ({ client }: { client: QueryClient }) => { + const backgroundImage = await renderNode(client, condIconConfig); + return { backgroundImage, key: condKey }; + }, + }); + } + } + return useQueries({ - queries: vtConfigs.map(vtConfig => ({ - queryKey: ["vertexIcon", vtConfig], - queryFn: async ({ client }) => { - const backgroundImage = await renderNode(client, vtConfig); - return { backgroundImage, type: vtConfig.type }; - }, - })), + queries: [...baseQueries, ...conditionalQueries], combine: results => results.reduce((map, item) => { if (item.data != null && item.data.backgroundImage != null) { - map.set(item.data.type, item.data.backgroundImage); + map.set(item.data.key, item.data.backgroundImage); } return map; }, new Map()), diff --git a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts index bf593ac62..5217de62b 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts @@ -1,14 +1,23 @@ import Color from "color"; +import { useAtomValue } from "jotai"; import { useDeferredValue } from "react"; import type { GraphProps } from "@/components/Graph"; import { + allEdgeTypeConfigsSelector, + allVertexTypeConfigsSelector, type EdgePreferences, + type EdgeTypeConfig, useAllEdgePreferences, useAllVertexPreferences, type VertexPreferences, + type VertexTypeConfig, } from "@/core"; +import { + buildConditionalEdgeSelector, + buildConditionalNodeSelector, +} from "@/core/StateProvider/conditionalStyling"; import { useBackgroundImageMap } from "./useBackgroundImageMap"; @@ -21,15 +30,24 @@ const LINE_PATTERN = { export default function useGraphStyles() { const vtConfigs = useAllVertexPreferences(); const etConfigs = useAllEdgePreferences(); + const vtTypeConfigs = useAtomValue(allVertexTypeConfigsSelector); + const etTypeConfigs = useAtomValue(allEdgeTypeConfigsSelector); const deferredVtConfigs = useDeferredValue(vtConfigs); const deferredEtConfigs = useDeferredValue(etConfigs); + const deferredVtTypeConfigs = useDeferredValue(vtTypeConfigs); + const deferredEtTypeConfigs = useDeferredValue(etTypeConfigs); - const backgroundImageMap = useBackgroundImageMap(deferredVtConfigs); + const backgroundImageMap = useBackgroundImageMap( + deferredVtConfigs, + deferredVtTypeConfigs, + ); return createGraphStyles( deferredVtConfigs, deferredEtConfigs, + deferredVtTypeConfigs, + deferredEtTypeConfigs, backgroundImageMap, ); } @@ -37,6 +55,8 @@ export default function useGraphStyles() { function createGraphStyles( deferredVtConfigs: VertexPreferences[], deferredEtConfigs: EdgePreferences[], + vtTypeConfigs: Map, + etTypeConfigs: Map, backgroundImageMap: Map, ): GraphProps["styles"] { const styles: GraphProps["styles"] = {}; @@ -60,6 +80,38 @@ function createGraphStyles( }; } + // Conditional vertex selectors — override base styles when condition is met + for (const vtTypeConfig of vtTypeConfigs.values()) { + if (!vtTypeConfig.condition || !vtTypeConfig.conditionalStyle) continue; + const attrConfig = vtTypeConfig.attributes.find( + a => a.name === vtTypeConfig.condition!.property, + ); + const selector = buildConditionalNodeSelector( + vtTypeConfig.type, + vtTypeConfig.condition, + attrConfig?.dataType, + ); + const cs = vtTypeConfig.conditionalStyle; + styles[selector] = { + ...(cs.color && { "background-color": cs.color }), + ...(cs.borderColor && { "border-color": cs.borderColor }), + ...(cs.borderWidth !== undefined && { "border-width": cs.borderWidth }), + ...(cs.borderStyle && { "border-style": cs.borderStyle }), + ...(cs.backgroundOpacity !== undefined && { + "background-opacity": cs.backgroundOpacity, + }), + ...(cs.shape && { shape: cs.shape }), + }; + const condImage = backgroundImageMap.get(`${vtTypeConfig.type}:cond`); + if (condImage) { + styles[selector] = { + ...styles[selector], + "background-image": condImage, + "background-fit": "contain", + }; + } + } + for (const etConfig of deferredEtConfigs) { const et = etConfig?.type; @@ -88,5 +140,28 @@ function createGraphStyles( "target-distance-from-node": 0, }; } + + // Conditional edge selectors — override base styles when condition is met + for (const etTypeConfig of etTypeConfigs.values()) { + if (!etTypeConfig.condition || !etTypeConfig.conditionalStyle) continue; + const attrConfig = etTypeConfig.attributes.find( + a => a.name === etTypeConfig.condition!.property, + ); + const selector = buildConditionalEdgeSelector( + etTypeConfig.type, + etTypeConfig.condition, + attrConfig?.dataType, + ); + const cs = etTypeConfig.conditionalStyle; + styles[selector] = { + ...(cs.lineColor && { "line-color": cs.lineColor }), + ...(cs.lineThickness !== undefined && { width: cs.lineThickness }), + ...(cs.lineStyle && { "line-style": cs.lineStyle }), + ...(cs.sourceArrowStyle && { "source-arrow-shape": cs.sourceArrowStyle }), + ...(cs.targetArrowStyle && { "target-arrow-shape": cs.targetArrowStyle }), + ...(cs.labelColor && { "text-background-color": cs.labelColor }), + }; + } + return styles; } diff --git a/packages/graph-explorer/src/modules/NodesStyling/ConditionalSection.test.tsx b/packages/graph-explorer/src/modules/NodesStyling/ConditionalSection.test.tsx new file mode 100644 index 000000000..2e6eadcf3 --- /dev/null +++ b/packages/graph-explorer/src/modules/NodesStyling/ConditionalSection.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; + +import type { StyleCondition } from "@/core/StateProvider/conditionalStyling"; + +import { ConditionalSection } from "./ConditionalSection"; + +const attrs = [ + { name: "known_bad", dataType: "Boolean" }, + { name: "score", dataType: "Number" }, + { name: "identifier_type", dataType: "String" }, +]; + +it("renders the value input", () => { + render( + , + ); + expect(screen.getByLabelText("Condition value")).toBeInTheDocument(); +}); + +function StatefulWrapper(props: { + initial: StyleCondition; + onChangeSpy: (c: StyleCondition) => void; +}) { + const [condition, setCondition] = useState(props.initial); + + function handleChange(next: StyleCondition) { + setCondition(next); + props.onChangeSpy(next); + } + + return ( + + ); +} + +it("calls onChange when value changes", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + ", value: "50" }} + onChangeSpy={onChange} + />, + ); + const input = screen.getByLabelText("Condition value"); + await user.clear(input); + await user.type(input, "90"); + expect(onChange).toHaveBeenLastCalledWith( + expect.objectContaining({ value: "90" }), + ); +}); diff --git a/packages/graph-explorer/src/modules/NodesStyling/ConditionalSection.tsx b/packages/graph-explorer/src/modules/NodesStyling/ConditionalSection.tsx new file mode 100644 index 000000000..c66c8a244 --- /dev/null +++ b/packages/graph-explorer/src/modules/NodesStyling/ConditionalSection.tsx @@ -0,0 +1,113 @@ +import type { AttributeConfig } from "@/core/ConfigurationProvider/types"; + +import { + Field, + FieldGroup, + FieldLabel, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components"; +import { + type ConditionOperator, + type StyleCondition, + validOperatorsForDataType, +} from "@/core/StateProvider/conditionalStyling"; + +interface Props { + attributes: AttributeConfig[]; + condition: StyleCondition | undefined; + onChange: (condition: StyleCondition) => void; +} + +const OPERATOR_LABELS: Record = { + "=": "= equals", + "!=": "≠ not equals", + ">": "> greater than", + "<": "< less than", + ">=": "≥ greater or equal", + "<=": "≤ less or equal", +}; + +export function ConditionalSection({ attributes, condition, onChange }: Props) { + const selectedAttr = attributes.find(a => a.name === condition?.property); + const allowedOps = validOperatorsForDataType(selectedAttr?.dataType); + + function handlePropertyChange(property: string) { + const newAttr = attributes.find(a => a.name === property); + const ops = validOperatorsForDataType(newAttr?.dataType); + const op = + condition?.operator && ops.includes(condition.operator) + ? condition.operator + : ops[0]; + onChange({ property, operator: op, value: condition?.value ?? "" }); + } + + function handleOperatorChange(operator: ConditionOperator) { + onChange({ ...(condition ?? { property: "", value: "" }), operator }); + } + + function handleValueChange(value: string) { + onChange({ ...(condition ?? { property: "", operator: "=" }), value }); + } + + return ( + + + Property + + + + Operator + - setVertexStyle({ displayNameAttribute: value }) - } - > - - - - - {selectOptions.map(option => ( - - {option.label} - - ))} - - - - - Display Description {t("property")} - - - - -
+
+ + +
+ + {activePane === "base" && ( +
+ - Shape + Display Name {t("property")} -
-
- Icon -
- - setVertexStyle({ iconUrl, iconImageType }) - } - /> - { - if (file) { - convertImageToBase64AndSetNewIcon(file); - } - }} - variant="outline" - className="rounded-full" - > - - Upload - - -
-
-
-
- -
- - {t("node")} Color - setVertexStyle({ color })} - /> - - - Background Opacity - - setVertexStyle({ - backgroundOpacity: parseNumberSafely(e.target.value), - }) - } - /> - -
-
- - Border Color - - setVertexStyle({ borderColor: color }) - } - /> - - - Border Width - - setVertexStyle({ - borderWidth: parseNumberSafely(e.target.value), - }) - } - /> - - - Border Style + Display Description {t("property")} -
-
- + + +
+ + Shape + + +
+
+ + Icon +
+ + setVertexStyle({ iconUrl, iconImageType }) + } + /> + { + if (file) { + convertImageToBase64AndSetNewIcon(file); + } + }} + variant="outline" + className="rounded-full" + > + + Upload + + +
+
+
+
+ +
+ + {t("node")} Color + + setVertexStyle({ color }) + } + /> + + + Background Opacity + + setVertexStyle({ + backgroundOpacity: parseNumberSafely(e.target.value), + }) + } + /> + +
+
+ + Border Color + + setVertexStyle({ borderColor: color }) + } + /> + + + Border Width + + setVertexStyle({ + borderWidth: parseNumberSafely(e.target.value), + }) + } + /> + + + Border Style + + +
+
+ + )} + + {activePane === "conditional" && ( +
+ + setConditionalVertexStyle( + cond, + rawStyle.conditionalStyle ?? {}, + ) + } + /> + +
+ + Shape + + +
+
+ + Icon +
+ { + if (!rawStyle.condition) return; + setConditionalVertexStyle(rawStyle.condition, { + ...rawStyle.conditionalStyle, + iconUrl, + iconImageType, + }); + }} + /> + +
+
+
+
+ +
+ + {t("node")} Color + { + if (!rawStyle.condition) return; + setConditionalVertexStyle(rawStyle.condition, { + ...rawStyle.conditionalStyle, + color, + }); + }} + /> + + + Background Opacity + { + if (!rawStyle.condition) return; + setConditionalVertexStyle(rawStyle.condition, { + ...rawStyle.conditionalStyle, + backgroundOpacity: parseNumberSafely(e.target.value), + }); + }} + /> + +
+
+ + Border Color + { + if (!rawStyle.condition) return; + setConditionalVertexStyle(rawStyle.condition, { + ...rawStyle.conditionalStyle, + borderColor: color, + }); + }} + /> + + + Border Width + { + if (!rawStyle.condition) return; + setConditionalVertexStyle(rawStyle.condition, { + ...rawStyle.conditionalStyle, + borderWidth: parseNumberSafely(e.target.value), + }); + }} + /> + + + Border Style + + +
+
+ {rawStyle.condition && ( + + )} +
+ )} - + {activePane === "base" && ( + + )} diff --git a/packages/graph-explorer/src/routes/Settings/useExportStylingFile.test.tsx b/packages/graph-explorer/src/routes/Settings/useExportStylingFile.test.tsx index 499fa1764..1d58c074e 100644 --- a/packages/graph-explorer/src/routes/Settings/useExportStylingFile.test.tsx +++ b/packages/graph-explorer/src/routes/Settings/useExportStylingFile.test.tsx @@ -86,4 +86,29 @@ describe("useExportStylingFile", () => { saveSpy.mockRestore(); }); + + it("should export condition and conditionalStyle", async () => { + const state = new DbState(); + state.setConditionalVertexStyle( + createVertexType("Customer"), + { property: "known_bad", operator: "=" as const, value: "true" }, + { color: "#D32F2F" }, + ); + + const saveSpy = vi.spyOn(fileData, "saveFile").mockResolvedValue(undefined); + + const { result } = renderHookWithState(() => useExportStylingFile(), state); + + await act(async () => { + await result.current(); + }); + + const blob = saveSpy.mock.calls[0][0]; + const text = await blob.text(); + const parsed = JSON.parse(text); + expect(parsed.vertices?.Customer?.condition?.property).toBe("known_bad"); + expect(parsed.vertices?.Customer?.conditionalStyle?.color).toBe("#D32F2F"); + + saveSpy.mockRestore(); + }); }); diff --git a/packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx b/packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx index 408087552..743fae171 100644 --- a/packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx +++ b/packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx @@ -126,4 +126,34 @@ describe("useImportStylingFile", () => { expect(toast.success).toHaveBeenCalled(); }); + + it("should import condition and conditionalStyle from file", async () => { + const state = new DbState(); + state.activeStyling = {}; + const { result } = renderHookWithState(() => useImportStylingFile(), state); + + const styling = { + vertices: { + Customer: { + color: "#0D47A1", + condition: { property: "known_bad", operator: "=", value: "true" }, + conditionalStyle: { color: "#D32F2F" }, + }, + }, + }; + + 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); + const vertex = imported.vertices?.find(v => v.type === "Customer"); + expect(vertex?.condition?.property).toBe("known_bad"); + expect(vertex?.conditionalStyle?.color).toBe("#D32F2F"); + }); }); diff --git a/packages/graph-explorer/src/utils/testing/DbState.ts b/packages/graph-explorer/src/utils/testing/DbState.ts index 0fbdddc68..6b9cdeb18 100644 --- a/packages/graph-explorer/src/utils/testing/DbState.ts +++ b/packages/graph-explorer/src/utils/testing/DbState.ts @@ -1,4 +1,5 @@ import type { Explorer } from "@/connector"; +import type { StyleCondition } from "@/core/StateProvider/conditionalStyling"; import { activeConfigurationAtom, @@ -180,6 +181,35 @@ export class DbState { return this; } + /** + * Sets a conditional style for the given vertex type. + * @param type The vertex type to set the conditional style for. + * @param condition The condition to apply. + * @param style The style to apply when the condition is met. + */ + setConditionalVertexStyle( + type: VertexType, + condition: StyleCondition, + style: Omit< + VertexPreferencesStorageModel, + "type" | "condition" | "conditionalStyle" + >, + ) { + this.activeStyling.vertices ??= []; + const existing = this.activeStyling.vertices.find(v => v.type === type); + if (existing) { + existing.condition = condition; + existing.conditionalStyle = style; + } else { + this.activeStyling.vertices.push({ + type, + condition, + conditionalStyle: style, + }); + } + 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. diff --git a/vitest.config.ts b/vitest.config.ts index b291732a8..03685b5ed 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,10 +6,10 @@ export default defineConfig({ coverage: { thresholds: { autoUpdate: (newThreshold: number) => Math.floor(newThreshold), - statements: 64, - branches: 45, - functions: 58, - lines: 72, + statements: 66, + branches: 46, + functions: 57, + lines: 73, }, }, },