Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
7bc754b
feat(conditional): add StyleCondition types and Cytoscape selector bu…
jkemmererupgrade May 28, 2026
6a34ed1
refactor(conditional): remove unused isNumericOperator and NUMERIC_OP…
jkemmererupgrade May 28, 2026
eaf066c
feat(conditional): extend vertex/edge storage models with condition +…
jkemmererupgrade May 28, 2026
c4a165a
feat(conditional): expose source vertex attributes as prop_ keys in C…
jkemmererupgrade May 28, 2026
676e251
feat(conditional): generate conditional Cytoscape selectors and suppo…
jkemmererupgrade May 28, 2026
4c95561
feat(conditional): extend styling file schema and resolve/export for …
jkemmererupgrade May 28, 2026
3200865
feat(conditional): add ConditionalSection condition-builder component
jkemmererupgrade May 28, 2026
a51602f
feat(conditional): add Base/Conditional tab toggle to NodeStyleDialog
jkemmererupgrade May 28, 2026
955b408
feat(conditional): add Base/Conditional tab toggle to EdgeStyleDialog
jkemmererupgrade May 28, 2026
ff06bd3
feat(conditional): import/export/reset round-trip tests for condition…
jkemmererupgrade May 28, 2026
f12dad5
docs: document conditional styling in node and edge style panels
jkemmererupgrade May 28, 2026
55a36b7
test: add coverage for conditional edge style set and remove operations
jkemmererupgrade May 28, 2026
023320e
fix(conditional): coerce Date attributes to ISO strings for Cytoscape…
jkemmererupgrade May 28, 2026
129cc29
chore: adjust coverage thresholds after rebase onto slice 1 review fe…
jkemmererupgrade May 28, 2026
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
2 changes: 2 additions & 0 deletions docs/features/graph-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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 ["=", "!="];
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type RenderedEdgeId,
type RenderedVertexId,
useRenderedEntities,
useRenderedVertices,
} from "./renderedEntities";

describe("createRenderedVertexId", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
47 changes: 36 additions & 11 deletions packages/graph-explorer/src/core/StateProvider/renderedEntities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type DisplayVertex,
edgesFilteredIdsAtom,
edgesTypesFilteredAtom,
type EntityPropertyValue,
type EntityRawId,
nodesFilteredIdsAtom,
nodesTypesFilteredAtom,
Expand All @@ -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<string, "RenderedVertexId">;

/** A string representation of an edge ID that encodes the original type. Cytoscape requires IDs to be strings. */
export type RenderedEdgeId = Branded<string, "RenderedEdgeId">;

/**
* 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<typeof createRenderedVertex>;
export type RenderedVertex = { data: RenderedVertexData };

/** A representation of an edge that Cytoscape can use. */
export type RenderedEdge = ReturnType<typeof createRenderedEdge>;
Expand Down Expand Up @@ -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 };
}

/**
Expand Down
Loading