diff --git a/docs/features/graph-view.md b/docs/features/graph-view.md index d33ab26f2..8cbff7977 100644 --- a/docs/features/graph-view.md +++ b/docs/features/graph-view.md @@ -83,6 +83,7 @@ On the **Nodes** tab, 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 searched or paged through in 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 +- **Conditional style** applies a different appearance to individual nodes of the type when they meet a condition (see [Conditional Styling](#conditional-styling)) On the **Edges** tab, each edge type can be customized in a variety of ways. @@ -91,6 +92,24 @@ On the **Edges** tab, 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** applies a different appearance to individual edges of the type when they meet a condition (see [Conditional Styling](#conditional-styling)) + +### Conditional Styling + +Both the node and edge styling panels include a **Conditional Style** toggle. Enable it to flag individual entities that meet a condition — for example, coloring `Person` nodes red when `known_bad = true`, without creating a separate type. The base style applies to all entities of the type; the conditional style is layered on top of the base only for the entities that match. + +A condition has three parts: + +- **Attribute** — the property to test, chosen from the type's attributes (or the node/edge id and type) +- **Operator** — `equals`, `not equals`, `greater than`, `less than`, `greater than or equal`, or `less than or equal` +- **Value** — the value to compare against + +Comparisons are type-aware: numbers compare numerically, dates compare chronologically (both `2025-03-03` and `3/3/2025` formats are understood), and other values compare as text. An entity that does not have the attribute never matches. Selection highlighting and focus dimming always take visual priority over a conditional style. + +The conditional style reuses the same pickers as the base style and only overrides the properties you change — anything you leave alone inherits from the base style. Conditional styles are included when you save your styles to share and are restored on import. + +> [!NOTE] +> Each type supports one condition. The condition is evaluated against the primary type of each rendered entity. ### Namespace Panel diff --git a/docs/features/settings.md b/docs/features/settings.md index 4b4c092b4..0d580f120 100644 --- a/docs/features/settings.md +++ b/docs/features/settings.md @@ -31,6 +31,8 @@ Only the types you select change. Anything not in the file stays as it is, and a For larger files, filter the preview by **All**, **Nodes**, **Edges**, **New** (types you haven't styled yet), or **Existing** (types that already have a style the file would replace), or search by type name. **Select all** toggles just the types currently shown. Filtering only changes what you see. Your selections are preserved, so **Load N selected** always applies every type you kept checked, across every tab. +A type that carries a [conditional style](./graph-view.md#conditional-styling) shows a second tile right after its base tile, labeled ` · conditional`. It previews the base appearance next to the condition-met appearance and lists the condition, with its own checkbox. Load it to bring in the base style and the condition together, or load only the base tile to take the base style without the condition. + ### Reset your styles Clears all your node and edge styles, returning every type to the defaults. diff --git a/packages/graph-explorer/src/components/ConditionBuilder.test.tsx b/packages/graph-explorer/src/components/ConditionBuilder.test.tsx new file mode 100644 index 000000000..25b3b7a5b --- /dev/null +++ b/packages/graph-explorer/src/components/ConditionBuilder.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom +import { fireEvent, render, screen } from "@testing-library/react"; + +import { ConditionBuilder, createDefaultCondition } from "./ConditionBuilder"; + +describe("createDefaultCondition", () => { + it("uses the first attribute option with an equals operator and empty value", () => { + expect( + createDefaultCondition([ + { label: "Score", value: "score" }, + { label: "Name", value: "name" }, + ]), + ).toStrictEqual({ attribute: "score", operator: "=", value: "" }); + }); + + it("falls back to an empty attribute when there are no options", () => { + expect(createDefaultCondition([])).toStrictEqual({ + attribute: "", + operator: "=", + value: "", + }); + }); +}); + +describe("ConditionBuilder", () => { + it("emits the updated condition when the value changes", () => { + const onChange = vi.fn(); + render( + ", value: "10" }} + attributeOptions={[{ label: "Score", value: "score" }]} + onChange={onChange} + />, + ); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "20" }, + }); + + expect(onChange).toHaveBeenCalledWith({ + attribute: "score", + operator: ">", + value: "20", + }); + }); + + it("hides the case-sensitivity checkbox for an ordering operator", () => { + render( + ", value: "10" }} + attributeOptions={[{ label: "Score", value: "score" }]} + onChange={vi.fn()} + />, + ); + + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + }); + + it("shows a checked case-sensitivity checkbox by default for equals", () => { + render( + , + ); + + expect( + screen.getByRole("checkbox", { name: /case sensitive/i }), + ).toBeChecked(); + }); + + it("emits caseSensitive: false when the checkbox is unchecked for matches", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("checkbox", { name: /case sensitive/i })); + + expect(onChange).toHaveBeenCalledWith({ + attribute: "score", + operator: "matches", + value: "Jo*", + caseSensitive: false, + }); + }); +}); diff --git a/packages/graph-explorer/src/components/ConditionBuilder.tsx b/packages/graph-explorer/src/components/ConditionBuilder.tsx new file mode 100644 index 000000000..06153993f --- /dev/null +++ b/packages/graph-explorer/src/components/ConditionBuilder.tsx @@ -0,0 +1,118 @@ +import { + CONDITION_OPERATOR_LABELS, + CONDITION_OPERATORS, + type ConditionOperator, + type StyleCondition, +} from "@/core/StateProvider/graphStyles"; + +import { Checkbox } from "./Checkbox"; +import { Field, FieldLabel } from "./Field"; +import { Input } from "./Input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./Select"; + +/** The operators whose text comparison can be toggled case-sensitive or not. */ +const CASE_SENSITIVITY_OPERATORS = new Set([ + "=", + "!=", + "matches", +]); + +export type AttributeOption = { label: string; value: string }; + +type ConditionBuilderProps = { + condition: StyleCondition; + attributeOptions: AttributeOption[]; + onChange: (condition: StyleCondition) => void; +}; + +/** The condition applied to a fresh conditional style: the first available attribute, an equals test, and an empty value. */ +export function createDefaultCondition( + attributeOptions: AttributeOption[], +): StyleCondition { + return { + attribute: attributeOptions[0]?.value ?? "", + operator: "=", + value: "", + }; +} + +/** + * Builds a single styling condition — an attribute, a comparison operator, and + * a value to compare against. Entity-agnostic: the caller supplies the + * attribute options (vertex or edge) and receives the updated condition. + */ +export function ConditionBuilder({ + condition, + attributeOptions, + onChange, +}: ConditionBuilderProps) { + return ( +
+ + Attribute + + + + Operator + + + + Value + + onChange({ ...condition, value: event.target.value }) + } + /> + {CASE_SENSITIVITY_OPERATORS.has(condition.operator) ? ( + + + onChange({ ...condition, caseSensitive: checked === true }) + } + aria-label="Case sensitive" + /> + Case sensitive + + ) : null} + +
+ ); +} diff --git a/packages/graph-explorer/src/components/Graph/hooks/useManageStyles.test.ts b/packages/graph-explorer/src/components/Graph/hooks/useManageStyles.test.ts new file mode 100644 index 000000000..123448c9f --- /dev/null +++ b/packages/graph-explorer/src/components/Graph/hooks/useManageStyles.test.ts @@ -0,0 +1,31 @@ +import { getStyles } from "./useManageStyles"; + +/** + * Cytoscape resolves its stylesheet as an order-based cascade: for a given + * element, a property set by a later rule overrides the same property from an + * earlier rule (there is no CSS-style specificity). Conditional styling relies + * on this — the conditional selectors arrive through the external `styles` + * object, which `getStyles` inserts before the selection / dimming / hidden + * overrides. This test locks that ordering so a conditionally-styled entity + * still shows selection and out-of-focus dimming. + */ +describe("getStyles conditional precedence", () => { + it("orders external (conditional) selectors before the state overrides", () => { + const conditionalSelector = 'node[type="Person"][conditionMet = "true"]'; + + const rootStyles = getStyles({ + styles: { [conditionalSelector]: { "background-color": "#ff0000" } }, + layout: "FORCE", + }); + + const selectors = rootStyles.map(style => style.selector); + const conditionalIndex = selectors.indexOf(conditionalSelector); + + expect(conditionalIndex).toBeGreaterThanOrEqual(0); + expect(conditionalIndex).toBeLessThan(selectors.indexOf("node:selected")); + expect(conditionalIndex).toBeLessThan(selectors.indexOf("node.hidden")); + expect(conditionalIndex).toBeLessThan( + selectors.indexOf("node.out-of-focus"), + ); + }); +}); diff --git a/packages/graph-explorer/src/components/index.ts b/packages/graph-explorer/src/components/index.ts index 5b9faeb7e..dae55db32 100644 --- a/packages/graph-explorer/src/components/index.ts +++ b/packages/graph-explorer/src/components/index.ts @@ -88,6 +88,8 @@ export * from "./SettingsPage"; export * from "./SidebarTabs"; +export * from "./ConditionBuilder"; + export * from "./Switch"; export * from "./TextArea"; 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..f1478f790 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/conditionalStyling.test.ts @@ -0,0 +1,202 @@ +import { createEdgeType, createVertexType } from "@/core"; + +import type { ConditionOperator, StyleCondition } from "./graphStyles"; + +import { + buildConditionalEdgeSelector, + buildConditionalNodeSelector, + CONDITION_MET_DATA_KEY, + evaluateStyleCondition, + formatStyleCondition, +} from "./conditionalStyling"; + +function condition( + attribute: string, + operator: ConditionOperator, + value: string, + caseSensitive?: boolean, +): StyleCondition { + return caseSensitive === undefined + ? { attribute, operator, value } + : { attribute, operator, value, caseSensitive }; +} + +describe("evaluateStyleCondition", () => { + it("returns false when the attribute value is missing", () => { + expect(evaluateStyleCondition(undefined, condition("x", "=", "1"))).toBe( + false, + ); + }); + + describe("equality", () => { + it("matches an equal string", () => { + expect( + evaluateStyleCondition("flagged", condition("s", "=", "flagged")), + ).toBe(true); + }); + + it("matches a boolean against its string form", () => { + expect(evaluateStyleCondition(true, condition("b", "=", "true"))).toBe( + true, + ); + expect(evaluateStyleCondition(false, condition("b", "=", "true"))).toBe( + false, + ); + }); + + it("matches a number against its numeric string", () => { + expect(evaluateStyleCondition(42, condition("n", "=", "42"))).toBe(true); + }); + + it("supports not-equals", () => { + expect( + evaluateStyleCondition("ok", condition("s", "!=", "flagged")), + ).toBe(true); + }); + + it("is case-sensitive by default", () => { + expect(evaluateStyleCondition("John", condition("s", "=", "john"))).toBe( + false, + ); + }); + + it("ignores case when caseSensitive is false", () => { + expect( + evaluateStyleCondition("John", condition("s", "=", "john", false)), + ).toBe(true); + expect( + evaluateStyleCondition("John", condition("s", "!=", "john", false)), + ).toBe(false); + }); + }); + + describe("matches pattern", () => { + it("matches a literal value with no wildcard", () => { + expect( + evaluateStyleCondition("flagged", condition("s", "matches", "flagged")), + ).toBe(true); + expect( + evaluateStyleCondition("flagged", condition("s", "matches", "other")), + ).toBe(false); + }); + + it("matches * as a prefix, suffix, and infix wildcard", () => { + expect( + evaluateStyleCondition( + "John Smith", + condition("s", "matches", "John*"), + ), + ).toBe(true); + expect( + evaluateStyleCondition( + "John Smith", + condition("s", "matches", "*Smith"), + ), + ).toBe(true); + expect( + evaluateStyleCondition( + "John Smith", + condition("s", "matches", "*n Sm*"), + ), + ).toBe(true); + expect( + evaluateStyleCondition( + "John Smith", + condition("s", "matches", "Smith*"), + ), + ).toBe(false); + }); + + it("is case-sensitive by default", () => { + expect( + evaluateStyleCondition( + "John Smith", + condition("s", "matches", "john*"), + ), + ).toBe(false); + }); + + it("ignores case when caseSensitive is false", () => { + expect( + evaluateStyleCondition( + "John Smith", + condition("s", "matches", "john*", false), + ), + ).toBe(true); + }); + + it("escapes regex-significant characters in the literal portion", () => { + // The `.` is a literal dot in the pattern, not "any character" — "axb" + // must not match even though it would if `.` were treated as regex. + expect( + evaluateStyleCondition("a.b", condition("s", "matches", "a.b")), + ).toBe(true); + expect( + evaluateStyleCondition("axb", condition("s", "matches", "a.b")), + ).toBe(false); + }); + + it("returns false when the attribute value is missing", () => { + expect( + evaluateStyleCondition(undefined, condition("s", "matches", "*")), + ).toBe(false); + }); + }); + + describe("numeric ordering", () => { + it("compares numbers numerically, not lexicographically", () => { + expect(evaluateStyleCondition(90, condition("n", ">", "50"))).toBe(true); + expect(evaluateStyleCondition(9, condition("n", ">", "50"))).toBe(false); + // Lexicographically "9" > "50", so this proves numeric comparison. + expect(evaluateStyleCondition(9, condition("n", "<", "50"))).toBe(true); + }); + }); + + describe("date ordering", () => { + it("compares ISO date strings chronologically", () => { + const after = condition("d", ">", "2025-03-03"); + expect(evaluateStyleCondition("2026-01-01", after)).toBe(true); + expect(evaluateStyleCondition("2024-01-01", after)).toBe(false); + }); + + it("compares non-ISO (M/D/YYYY) date strings chronologically", () => { + // The bug: lexicographically "6/1/2024" > "2025-03-03" because '6' > '2'. + // Chronologically it is earlier and must not match ">". + const after = condition("d", ">", "2025-03-03"); + expect(evaluateStyleCondition("6/1/2026", after)).toBe(true); + expect(evaluateStyleCondition("6/1/2024", after)).toBe(false); + }); + }); + + describe("string ordering fallback", () => { + it("compares plain strings lexicographically", () => { + expect( + evaluateStyleCondition("banana", condition("s", ">", "apple")), + ).toBe(true); + }); + }); +}); + +describe("formatStyleCondition", () => { + it("renders a compact attribute/operator/value summary", () => { + expect( + formatStyleCondition(condition("create_date", ">", "2025-03-03")), + ).toBe("create_date > 2025-03-03"); + }); +}); + +describe("buildConditionalNodeSelector", () => { + it("matches nodes of the type whose condition was met", () => { + expect(buildConditionalNodeSelector(createVertexType("Person"))).toBe( + `node[type="Person"][${CONDITION_MET_DATA_KEY} = "true"]`, + ); + }); +}); + +describe("buildConditionalEdgeSelector", () => { + it("matches edges of the type whose condition was met", () => { + expect(buildConditionalEdgeSelector(createEdgeType("KNOWS"))).toBe( + `edge[type="KNOWS"][${CONDITION_MET_DATA_KEY} = "true"]`, + ); + }); +}); 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..a7316096f --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/conditionalStyling.ts @@ -0,0 +1,129 @@ +import type { EntityPropertyValue } from "@/core"; + +import type { EdgeType, VertexType } from "../entities"; +import type { StyleCondition } from "./graphStyles"; + +/** + * The Cytoscape data key stamped as `"true"` on a rendered entity when its + * type's styling condition is satisfied. The conditional selector matches on + * this flag, so the comparison itself is done here in JavaScript rather than by + * Cytoscape's attribute-selector operators — which compare lexicographically or + * via `parseFloat` and therefore mishandle dates and mixed types. + */ +export const CONDITION_MET_DATA_KEY = "conditionMet"; + +/** A compact, human-readable summary of a condition, e.g. `create_date > 2025-03-03`. */ +export function formatStyleCondition(condition: StyleCondition): string { + return `${condition.attribute} ${condition.operator} ${condition.value}`; +} + +/** + * Evaluates whether a rendered entity's attribute value satisfies the styling + * condition. A missing value never matches. Ordering operators compare + * numerically when both sides are numbers, chronologically when both parse as + * dates, and lexicographically otherwise, so `create_date > "2025-03-03"` + * behaves correctly regardless of the date's stored format. + */ +export function evaluateStyleCondition( + rawValue: EntityPropertyValue | undefined, + condition: StyleCondition, +): boolean { + if (rawValue === undefined || rawValue === null) { + return false; + } + + const caseSensitive = condition.caseSensitive !== false; + + switch (condition.operator) { + case "=": + return valuesEqual(rawValue, condition.value, caseSensitive); + case "!=": + return !valuesEqual(rawValue, condition.value, caseSensitive); + case ">": + return compareValues(rawValue, condition.value) > 0; + case "<": + return compareValues(rawValue, condition.value) < 0; + case ">=": + return compareValues(rawValue, condition.value) >= 0; + case "<=": + return compareValues(rawValue, condition.value) <= 0; + case "matches": + return matchesWildcard(rawValue, condition.value, caseSensitive); + } +} + +function valuesEqual( + rawValue: EntityPropertyValue, + value: string, + caseSensitive: boolean, +): boolean { + const rawString = String(rawValue); + if ( + caseSensitive + ? rawString === value + : rawString.toLowerCase() === value.toLowerCase() + ) { + return true; + } + const rawNumber = Number(rawValue); + const valueNumber = Number(value); + return ( + Number.isFinite(rawNumber) && + Number.isFinite(valueNumber) && + rawNumber === valueNumber + ); +} + +/** + * Matches `rawValue` against a `*`-wildcard pattern (`*` = any sequence of + * characters, including none). Every other character in `pattern` is matched + * literally, so regex-significant characters like `.` or `(` never gain + * special meaning just because a user typed them into a condition value. + */ +function matchesWildcard( + rawValue: EntityPropertyValue, + pattern: string, + caseSensitive: boolean, +): boolean { + const escaped = pattern + .split("*") + .map(segment => segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join(".*"); + const regex = new RegExp(`^${escaped}$`, caseSensitive ? "" : "i"); + return regex.test(String(rawValue)); +} + +/** + * Returns a negative, zero, or positive number when `rawValue` orders before, + * equal to, or after `value`. Prefers numeric comparison, then chronological + * (date) comparison, then lexicographic — so each type compares meaningfully. + */ +function compareValues(rawValue: EntityPropertyValue, value: string): number { + const rawNumber = Number(rawValue); + const valueNumber = Number(value); + if (Number.isFinite(rawNumber) && Number.isFinite(valueNumber)) { + return rawNumber - valueNumber; + } + + const rawDate = Date.parse(String(rawValue)); + const valueDate = Date.parse(value); + if (!Number.isNaN(rawDate) && !Number.isNaN(valueDate)) { + return rawDate - valueDate; + } + + const rawString = String(rawValue); + return rawString < value ? -1 : rawString > value ? 1 : 0; +} + +/** + * The Cytoscape selector for a vertex type whose condition is met. The match is + * driven by the {@link CONDITION_MET_DATA_KEY} flag stamped during rendering. + */ +export function buildConditionalNodeSelector(type: VertexType): string { + return `node[type="${type}"][${CONDITION_MET_DATA_KEY} = "true"]`; +} + +/** The Cytoscape selector for an edge type whose condition is met. */ +export function buildConditionalEdgeSelector(type: EdgeType): string { + return `edge[type="${type}"][${CONDITION_MET_DATA_KEY} = "true"]`; +} diff --git a/packages/graph-explorer/src/core/StateProvider/displayEdge.ts b/packages/graph-explorer/src/core/StateProvider/displayEdge.ts index e77376505..5733ce230 100644 --- a/packages/graph-explorer/src/core/StateProvider/displayEdge.ts +++ b/packages/graph-explorer/src/core/StateProvider/displayEdge.ts @@ -32,6 +32,7 @@ export type DisplayEdge = { targetId: VertexId; attributes: DisplayAttribute[]; hasUniqueId: boolean; + original: Edge; }; export function useDisplayEdgeInCanvas(edgeId: EdgeId) { @@ -117,6 +118,7 @@ const displayEdgeSelector = atomFamily((edge: Edge) => attributes: sortedAttributes, // SPARQL does not have unique ID values for predicates, so the UI should hide them hasUniqueId: isSparql === false, + original: edge, }; return displayEdge; }), diff --git a/packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts b/packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts index 0957a2df8..42e98c6cc 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphStyles.test.ts @@ -10,6 +10,11 @@ import { appDefaultVertexStyle, edgeStyleAtom, type EdgeStyleStorage, + resolveConditionalEdgeStyle, + resolveConditionalVertexStyle, + resolveEdgeStyle, + resolveVertexStyle, + type StyleCondition, useEdgeStyling, useVertexStyling, vertexStyleAtom, @@ -430,6 +435,74 @@ describe("vertexStyleAtom", () => { }); }); +describe("resolveConditionalVertexStyle", () => { + const condition: StyleCondition = { + attribute: "known_bad", + operator: "=", + value: "true", + }; + + it("returns undefined when the base style has no conditional style", () => { + const base = resolveVertexStyle(createVertexType("Person")); + + expect(resolveConditionalVertexStyle(base)).toBeUndefined(); + }); + + it("inherits the base style and applies the conditional overrides", () => { + const base = resolveVertexStyle(createVertexType("Person"), { + type: createVertexType("Person"), + color: "blue", + conditionalStyle: { condition, borderColor: "red", borderWidth: 4 }, + }); + + const resolved = resolveConditionalVertexStyle(base); + + expect(resolved).toStrictEqual({ + condition, + style: { + ...base, + borderColor: "red", + borderWidth: 4, + conditionalStyle: undefined, + }, + }); + }); +}); + +describe("resolveConditionalEdgeStyle", () => { + const condition: StyleCondition = { + attribute: "weight", + operator: ">", + value: "10", + }; + + it("returns undefined when the base style has no conditional style", () => { + const base = resolveEdgeStyle(createEdgeType("KNOWS")); + + expect(resolveConditionalEdgeStyle(base)).toBeUndefined(); + }); + + it("inherits the base style and applies the conditional overrides", () => { + const base = resolveEdgeStyle(createEdgeType("KNOWS"), { + type: createEdgeType("KNOWS"), + lineColor: "grey", + conditionalStyle: { condition, lineColor: "red", lineThickness: 6 }, + }); + + const resolved = resolveConditionalEdgeStyle(base); + + expect(resolved).toStrictEqual({ + condition, + style: { + ...base, + lineColor: "red", + lineThickness: 6, + conditionalStyle: undefined, + }, + }); + }); +}); + describe("edgeStyleAtom", () => { it("should return stored styles for a known type", () => { const dbState = new DbState(); diff --git a/packages/graph-explorer/src/core/StateProvider/graphStyles.ts b/packages/graph-explorer/src/core/StateProvider/graphStyles.ts index 8026c7a37..ecce4d84a 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphStyles.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphStyles.ts @@ -58,6 +58,53 @@ export const ARROW_STYLES = [ ] as const; export type ArrowStyle = (typeof ARROW_STYLES)[number]; +/** + * The comparison operators available for a conditional style. Matching is done + * in a JavaScript evaluation loop (`evaluateStyleCondition`), not by Cytoscape's + * native attribute selectors — those compare lexicographically or via + * `parseFloat`, which mishandles dates and mixed types. + */ +export const CONDITION_OPERATORS = [ + "=", + "!=", + ">", + "<", + ">=", + "<=", + "matches", +] as const; +export type ConditionOperator = (typeof CONDITION_OPERATORS)[number]; + +/** Human-readable labels for the condition operators, for the condition builder UI. */ +export const CONDITION_OPERATOR_LABELS: Record = { + "=": "equals", + "!=": "not equals", + ">": "greater than", + "<": "less than", + ">=": "greater than or equal", + "<=": "less than or equal", + matches: "matches pattern", +}; + +/** + * A single condition evaluated against a rendered entity's attribute. The value + * is stored as a string and coerced to the attribute's runtime type when the + * Cytoscape selector is built. + */ +export type StyleCondition = { + /** Schema attribute name, or a reserved id/type property. */ + attribute: string; + operator: ConditionOperator; + value: string; + /** + * Whether `=`, `!=`, and `matches` compare text exactly or ignoring case. + * Omitted (or `true`) means case-sensitive — the original behavior, so + * conditions from before this field existed are unaffected. Only `matches` + * treats `value` as a `*`-wildcard pattern; the others compare it verbatim. + */ + caseSensitive?: boolean; +}; + /** * The visual appearance of a vertex — the fields that make sense for both a * per-type style and a type-less global default. Every field is required: this @@ -115,26 +162,74 @@ export type EdgeTypeStyle = { displayNameAttribute: string; }; +/** + * The alternate appearance applied to a vertex when its {@link StyleCondition} + * matches. The style fields are partial overrides layered on the resolved base + * style — unset fields inherit from the base. + */ +export type VertexConditionalStyle = { condition: StyleCondition } & Partial< + VertexVisualStyle & VertexTypeStyle +>; + +/** The alternate appearance applied to an edge when its condition matches. */ +export type EdgeConditionalStyle = { condition: StyleCondition } & Partial< + EdgeVisualStyle & EdgeTypeStyle +>; + /** The style for the specified vertex type as stored in local storage. */ export type VertexStyleStorage = Simplify< - Partial & { type: VertexType } + Partial & { + type: VertexType; + conditionalStyle?: VertexConditionalStyle; + } >; /** The style for the specified edge type as stored in local storage. */ export type EdgeStyleStorage = Simplify< - Partial & { type: EdgeType } + Partial & { + type: EdgeType; + conditionalStyle?: EdgeConditionalStyle; + } >; /** The resolved style for the specified vertex type as an immutable object. */ export type VertexStyle = Simplify< - Readonly + Readonly< + VertexVisualStyle & + VertexTypeStyle & { + type: VertexType; + conditionalStyle?: VertexConditionalStyle; + } + > >; /** The resolved style for the specified edge type as an immutable object. */ export type EdgeStyle = Simplify< - Readonly + Readonly< + EdgeVisualStyle & + EdgeTypeStyle & { + type: EdgeType; + conditionalStyle?: EdgeConditionalStyle; + } + > >; +/** + * A vertex's resolved conditional appearance paired with the condition that + * activates it. Produced by {@link resolveConditionalVertexStyle} and consumed + * by the graph-canvas style generation to emit a conditional Cytoscape selector. + */ +export type ResolvedConditionalVertexStyle = { + condition: StyleCondition; + style: VertexStyle; +}; + +/** An edge's resolved conditional appearance paired with its activating condition. */ +export type ResolvedConditionalEdgeStyle = { + condition: StyleCondition; + style: EdgeStyle; +}; + /** The default values to use when no user provided value is given. */ export const appDefaultVertexStyle = { displayNameAttribute: RESERVED_ID_PROPERTY, @@ -243,6 +338,39 @@ export function resolveEdgeStyle( } as const; } +/** + * Resolves the conditional appearance for a vertex, if it has one. The + * conditional style inherits the resolved base style and layers its own partial + * overrides on top, so a user sets only the fields that differ. Returns + * `undefined` when the base style defines no condition. + */ +export function resolveConditionalVertexStyle( + base: VertexStyle, +): ResolvedConditionalVertexStyle | undefined { + if (!base.conditionalStyle) { + return undefined; + } + const { condition, ...overrides } = base.conditionalStyle; + return { + condition, + style: { ...base, ...overrides, conditionalStyle: undefined }, + }; +} + +/** Resolves the conditional appearance for an edge, if it has one. */ +export function resolveConditionalEdgeStyle( + base: EdgeStyle, +): ResolvedConditionalEdgeStyle | undefined { + if (!base.conditionalStyle) { + return undefined; + } + const { condition, ...overrides } = base.conditionalStyle; + return { + condition, + style: { ...base, ...overrides, conditionalStyle: undefined }, + }; +} + /** Returns an array of vertex styles based on the known vertex types in the schema. * Always includes an entry for `LABELS.MISSING_TYPE` so that blank nodes (which are * assigned that synthetic type at runtime) receive icon styling on the canvas. diff --git a/packages/graph-explorer/src/core/StateProvider/index.ts b/packages/graph-explorer/src/core/StateProvider/index.ts index a215ca8c7..290e00d73 100644 --- a/packages/graph-explorer/src/core/StateProvider/index.ts +++ b/packages/graph-explorer/src/core/StateProvider/index.ts @@ -10,6 +10,7 @@ export * from "./featureFlags"; export * from "./neighbors"; export * from "./nodes"; export * from "./renderedEntities"; +export * from "./conditionalStyling"; export * from "./graphStyles"; export * from "./schema"; export * from "./storageAtoms"; diff --git a/packages/graph-explorer/src/core/StateProvider/renderedEntities.test.ts b/packages/graph-explorer/src/core/StateProvider/renderedEntities.test.ts index 6e9c90e6b..601573bdc 100644 --- a/packages/graph-explorer/src/core/StateProvider/renderedEntities.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/renderedEntities.test.ts @@ -3,12 +3,14 @@ import { waitFor } from "@testing-library/react"; import { createEdgeId, + createEdgeType, createVertexId, createVertexType, } from "@/core/entities"; import { createRandomEdge, createRandomVertex, + createTestableEdge, createTestableVertex, DbState, renderHookWithJotai, @@ -139,6 +141,111 @@ describe("useRenderedVertices", () => { }); }); +describe("conditional styling match flag", () => { + it("stamps conditionMet when a vertex satisfies its type's condition", async () => { + const dbState = new DbState(); + const personType = createVertexType("Person"); + const vertex = createTestableVertex().with({ + types: [personType], + attributes: { score: 90 }, + }); + dbState.addTestableVertexToGraph(vertex); + dbState.addVertexStyle(personType, { + conditionalStyle: { + condition: { attribute: "score", operator: ">", value: "50" }, + borderColor: "red", + }, + }); + + const { result } = renderHookWithJotai( + () => useRenderedEntities(), + store => dbState.applyTo(store), + ); + + await waitFor(() => { + expect(result.current.vertices[0].data).toMatchObject({ + conditionMet: "true", + }); + }); + }); + + it("does not stamp conditionMet when the vertex fails its condition", async () => { + const dbState = new DbState(); + const personType = createVertexType("Person"); + const vertex = createTestableVertex().with({ + types: [personType], + attributes: { score: 10 }, + }); + dbState.addTestableVertexToGraph(vertex); + dbState.addVertexStyle(personType, { + conditionalStyle: { + condition: { attribute: "score", operator: ">", value: "50" }, + borderColor: "red", + }, + }); + + const { result } = renderHookWithJotai( + () => useRenderedEntities(), + store => dbState.applyTo(store), + ); + + await waitFor(() => { + expect(result.current.vertices[0].data).toMatchObject({ + conditionMet: "false", + }); + }); + }); + + it("stamps conditionMet false when the vertex type has no condition", async () => { + const dbState = new DbState(); + const vertex = createTestableVertex().with({ + types: [createVertexType("Person")], + attributes: { score: 90 }, + }); + dbState.addTestableVertexToGraph(vertex); + + const { result } = renderHookWithJotai( + () => useRenderedEntities(), + store => dbState.applyTo(store), + ); + + await waitFor(() => { + expect(result.current.vertices[0].data).toMatchObject({ + conditionMet: "false", + }); + }); + }); + + it("stamps conditionMet when an edge satisfies its type's condition", async () => { + const dbState = new DbState(); + const knowsType = createEdgeType("KNOWS"); + const source = createTestableVertex(); + const target = createTestableVertex(); + const edge = createTestableEdge() + .with({ type: knowsType, attributes: { weight: 5 } }) + .withSource(source) + .withTarget(target); + dbState.addTestableEdgeToGraph(edge); + dbState.addEdgeStyle(knowsType, { + conditionalStyle: { + condition: { attribute: "weight", operator: ">=", value: "3" }, + lineColor: "red", + }, + }); + + const { result } = renderHookWithJotai( + () => useRenderedEntities(), + store => dbState.applyTo(store), + ); + + await waitFor(() => { + expect(result.current.edges[0].data).toMatchObject({ + conditionMet: "true", + }); + }); + }); +}); + describe("useRenderedEdges", () => { it("should return the filtered edges by ID", async () => { const dbState = new DbState(); diff --git a/packages/graph-explorer/src/core/StateProvider/renderedEntities.ts b/packages/graph-explorer/src/core/StateProvider/renderedEntities.ts index d08814f5e..0e4b1bf24 100644 --- a/packages/graph-explorer/src/core/StateProvider/renderedEntities.ts +++ b/packages/graph-explorer/src/core/StateProvider/renderedEntities.ts @@ -7,7 +7,9 @@ import { type DisplayVertex, edgesFilteredIdsAtom, edgesTypesFilteredAtom, + type EntityPropertyValue, type EntityRawId, + getRawId, nodesFilteredIdsAtom, nodesTypesFilteredAtom, useAllNeighbors, @@ -15,9 +17,20 @@ import { useDisplayVerticesInCanvas, type VertexId, } from "@/core"; +import { RESERVED_ID_PROPERTY, RESERVED_TYPES_PROPERTY } from "@/utils"; import type { EdgeId } from "../entities/edge"; +import { + CONDITION_MET_DATA_KEY, + evaluateStyleCondition, +} from "./conditionalStyling"; +import { + edgeStyleAtom, + type StyleCondition, + vertexStyleAtom, +} from "./graphStyles"; + /** A string representation of a vertex ID that encodes the original type. Cytoscape requires IDs to be strings. */ export type RenderedVertexId = Branded; @@ -36,6 +49,7 @@ export function useRenderedVertices(): RenderedVertex[] { const filteredTypes = useAtomValue(nodesTypesFilteredAtom); const displayVerticesInGraph = useDisplayVerticesInCanvas(); const neighborCounts = useAllNeighbors(); + const vertexStyles = useAtomValue(vertexStyleAtom); const result: RenderedVertex[] = []; @@ -56,7 +70,9 @@ export function useRenderedVertices(): RenderedVertex[] { if (hasFilteredType) continue; const neighborCount = neighborCounts.get(vertex.id)?.unfetched ?? 0; - result.push(createRenderedVertex(vertex, neighborCount)); + const condition = vertexStyles.get(vertex.primaryType).conditionalStyle + ?.condition; + result.push(createRenderedVertex(vertex, neighborCount, condition)); } return result; @@ -68,6 +84,7 @@ export function useRenderedEdges(): RenderedEdge[] { const filteredEdgeIds = useAtomValue(edgesFilteredIdsAtom); const filteredEdgeTypes = useAtomValue(edgesTypesFilteredAtom); const vertices = useRenderedVertices(); + const edgeStyles = useAtomValue(edgeStyleAtom); // Get the IDs of the existing vertices const existingVertexIds = new Set(vertices.map(v => v.data.vertexId)); @@ -84,7 +101,8 @@ export function useRenderedEdges(): RenderedEdge[] { if (!existingVertexIds.has(edge.sourceId)) continue; if (!existingVertexIds.has(edge.targetId)) continue; - result.push(createRenderedEdge(edge)); + const condition = edgeStyles.get(edge.type).conditionalStyle?.condition; + result.push(createRenderedEdge(edge, condition)); } return result; @@ -165,8 +183,16 @@ function stripIdTypePrefix(id: string): string { * Cytoscape expects a few things: * - The `id` property is a string * - There exists a `data` property where any custom data is stored + * + * When the vertex type defines a conditional style, the condition is evaluated + * here and a `conditionMet` flag is stamped so the conditional Cytoscape + * selector can match the entities that satisfy it. */ -function createRenderedVertex(vertex: DisplayVertex, neighborCount: number) { +function createRenderedVertex( + vertex: DisplayVertex, + neighborCount: number, + condition?: StyleCondition, +) { return { data: { id: createRenderedVertexId(vertex.id), @@ -175,6 +201,9 @@ function createRenderedVertex(vertex: DisplayVertex, neighborCount: number) { displayName: vertex.displayName, displayTypes: vertex.displayTypes, neighborCount, + ...conditionMetData(condition, attribute => + resolveVertexAttributeValue(vertex, attribute), + ), }, }; } @@ -187,7 +216,7 @@ function createRenderedVertex(vertex: DisplayVertex, neighborCount: number) { * - The `source` and `target` properties are strings * - There exists a `data` property where any custom data is stored */ -function createRenderedEdge(edge: DisplayEdge) { +function createRenderedEdge(edge: DisplayEdge, condition?: StyleCondition) { return { data: { id: createRenderedEdgeId(edge.id), @@ -196,6 +225,58 @@ function createRenderedEdge(edge: DisplayEdge) { edgeId: edge.id, type: edge.type, displayName: edge.displayName, + ...conditionMetData(condition, attribute => + resolveEdgeAttributeValue(edge, attribute), + ), }, }; } + +/** + * Stamps the `conditionMet` flag as `"true"` or `"false"`. Always explicit, + * never omitted: Cytoscape's `data()` setter merges by key and never clears a + * key that a later update simply leaves out, so an entity that stops matching + * (or whose type's conditional style is removed) would keep a stale `"true"` + * forever on a live update — only a full remove-and-re-add would reflect the + * current truth. The comparison runs in JavaScript (see + * {@link evaluateStyleCondition}) so dates and mixed types compare correctly, + * rather than delegating to Cytoscape's lexicographic/`parseFloat` operators. + * The attribute's raw value is resolved by the caller so vertices and edges can + * each supply it from their own shape. + */ +function conditionMetData( + condition: StyleCondition | undefined, + resolveValue: (attribute: string) => EntityPropertyValue | undefined, +): Record { + const matched = Boolean( + condition && + evaluateStyleCondition(resolveValue(condition.attribute), condition), + ); + return { [CONDITION_MET_DATA_KEY]: matched ? "true" : "false" }; +} + +function resolveVertexAttributeValue( + vertex: DisplayVertex, + attribute: string, +): EntityPropertyValue | undefined { + if (attribute === RESERVED_ID_PROPERTY) { + return getRawId(vertex.id); + } + if (attribute === RESERVED_TYPES_PROPERTY) { + return vertex.primaryType; + } + return vertex.original.attributes[attribute]; +} + +function resolveEdgeAttributeValue( + edge: DisplayEdge, + attribute: string, +): EntityPropertyValue | undefined { + if (attribute === RESERVED_ID_PROPERTY) { + return getRawId(edge.id); + } + if (attribute === RESERVED_TYPES_PROPERTY) { + return edge.type; + } + return edge.original.attributes[attribute]; +} diff --git a/packages/graph-explorer/src/core/styling/stylingParser.test.ts b/packages/graph-explorer/src/core/styling/stylingParser.test.ts index 3084307f6..c8407e7f7 100644 --- a/packages/graph-explorer/src/core/styling/stylingParser.test.ts +++ b/packages/graph-explorer/src/core/styling/stylingParser.test.ts @@ -7,6 +7,7 @@ import { parseStylingPayload, parseStylingPayloadForVersion, StylingParseError, + toVertexFileEntry, } from "./stylingParser"; /** Parses, asserting failure, and returns the thrown issues for inspection. */ @@ -549,3 +550,195 @@ describe("parseStylingPayloadForVersion", () => { ).toThrow(FileEnvelopeError); }); }); + +describe("conditional styling", () => { + test("parses a vertex conditional style, renaming the nested icon to iconUrl", () => { + const result = parseStylingPayload({ + vertices: { + Person: { + color: "#111111", + conditionalStyle: { + condition: { + attribute: "known_bad", + operator: "=", + value: "true", + }, + borderColor: "#ff0000", + icon: "lucide:shield-alert", + }, + }, + }, + edges: {}, + }); + + expect(result.vertexStyles.get(createVertexType("Person"))).toStrictEqual({ + type: createVertexType("Person"), + color: "#111111", + conditionalStyle: { + condition: { attribute: "known_bad", operator: "=", value: "true" }, + borderColor: "#ff0000", + iconUrl: "lucide:shield-alert", + }, + }); + }); + + test("parses an edge conditional style", () => { + const result = parseStylingPayload({ + vertices: {}, + edges: { + KNOWS: { + lineColor: "#111111", + conditionalStyle: { + condition: { attribute: "weight", operator: ">", value: "10" }, + lineColor: "#ff0000", + }, + }, + }, + }); + + expect(result.edgeStyles.get(createEdgeType("KNOWS"))).toStrictEqual({ + type: createEdgeType("KNOWS"), + lineColor: "#111111", + conditionalStyle: { + condition: { attribute: "weight", operator: ">", value: "10" }, + lineColor: "#ff0000", + }, + }); + }); + + test("strips an injected iconUrl inside a conditional style", () => { + const result = parseStylingPayload({ + vertices: { + Person: { + conditionalStyle: { + condition: { attribute: "x", operator: "=", value: "1" }, + iconUrl: "https://evil.example.com/x.svg", + color: "#abcabc", + }, + }, + }, + edges: {}, + }); + + expect( + result.vertexStyles.get(createVertexType("Person"))!.conditionalStyle, + ).toStrictEqual({ + condition: { attribute: "x", operator: "=", value: "1" }, + color: "#abcabc", + }); + }); + + test("parses a conditional style with the matches operator and caseSensitive", () => { + const result = parseStylingPayload({ + vertices: { + Person: { + conditionalStyle: { + condition: { + attribute: "name", + operator: "matches", + value: "Jo*", + caseSensitive: false, + }, + color: "#ff0000", + }, + }, + }, + edges: {}, + }); + + expect( + result.vertexStyles.get(createVertexType("Person"))!.conditionalStyle, + ).toStrictEqual({ + condition: { + attribute: "name", + operator: "matches", + value: "Jo*", + caseSensitive: false, + }, + color: "#ff0000", + }); + }); + + test("parses a condition with no caseSensitive field, leaving it unset", () => { + const result = parseStylingPayload({ + vertices: { + Person: { + conditionalStyle: { + condition: { attribute: "x", operator: "=", value: "1" }, + color: "#abcabc", + }, + }, + }, + edges: {}, + }); + + expect( + result.vertexStyles.get(createVertexType("Person"))!.conditionalStyle + ?.condition, + ).toStrictEqual({ attribute: "x", operator: "=", value: "1" }); + }); + + test("rejects the whole file when a nested condition operator is invalid", () => { + const issues = parseExpectingIssues({ + vertices: { + Person: { + conditionalStyle: { + condition: { attribute: "x", operator: "??", value: "1" }, + }, + }, + }, + edges: {}, + }); + + expect(issues).toContainEqual( + expect.objectContaining({ + scope: "entry", + entityType: "vertex", + typeName: "Person", + field: "conditionalStyle.condition.operator", + }), + ); + }); + + test("rejects a nested icon that fails the allowlist", () => { + const issues = parseExpectingIssues({ + vertices: { + Person: { + conditionalStyle: { + condition: { attribute: "x", operator: "=", value: "1" }, + icon: "javascript:alert(1)", + }, + }, + }, + edges: {}, + }); + + expect(issues).toContainEqual( + expect.objectContaining({ + typeName: "Person", + field: "conditionalStyle.icon", + }), + ); + }); + + test("exports a conditional style, renaming the nested iconUrl to icon", () => { + const entry = toVertexFileEntry({ + type: createVertexType("Person"), + color: "#111111", + conditionalStyle: { + condition: { attribute: "x", operator: "=", value: "1" }, + iconUrl: "lucide:shield", + borderColor: "#ff0000", + }, + }); + + expect(entry).toStrictEqual({ + color: "#111111", + conditionalStyle: { + condition: { attribute: "x", operator: "=", value: "1" }, + icon: "lucide:shield", + borderColor: "#ff0000", + }, + }); + }); +}); diff --git a/packages/graph-explorer/src/core/styling/stylingParser.ts b/packages/graph-explorer/src/core/styling/stylingParser.ts index b38e2348d..6f75c0c49 100644 --- a/packages/graph-explorer/src/core/styling/stylingParser.ts +++ b/packages/graph-explorer/src/core/styling/stylingParser.ts @@ -8,9 +8,11 @@ import { createEdgeType, createVertexType } from "@/core/entities"; import { FileEnvelopeError } from "@/core/fileEnvelope"; import { ARROW_STYLES, + CONDITION_OPERATORS, type EdgeStyleStorage, LINE_STYLES, SHAPE_STYLES, + type VertexConditionalStyle, type VertexStyleStorage, } from "@/core/StateProvider/graphStyles"; import { typedEntries } from "@/utils"; @@ -112,39 +114,40 @@ export function isAllowedIconValue(value: string): boolean { } /** - * One vertex entry. Unknown fields are stripped (Zod's default), so a file with - * extra keys imports without error and without storing them — in particular an - * injected `iconUrl` is dropped, never bypassing the `icon` allowlist. The - * `icon`→`iconUrl` rename to the storage model happens in `.transform()`, so it - * stays at this seam. + * A styling condition. The value is always a string in the file; coercion to + * the attribute's runtime type happens when the Cytoscape selector is built. */ -const vertexEntrySchema = z - .object({ - icon: safeIconValue.optional(), - // Loose `string`, matching storage. The upload seam fills this from the - // browser's `file.type` (any `image/*` the OS reports), so a fixed enum - // would reject valid uploads on round-trip. It is not a security boundary: - // the icon data is guarded by `safeIconValue`, and the only consumer that - // reads this field does an exact match on `"image/svg+xml"` to choose the - // inline-SVG render path — which is DOMPurify-sanitized — so any other - // value simply takes the safer ``/raster path. - iconImageType: z.string().optional(), - color: z.string().optional(), - displayLabel: z.string().optional(), - displayNameAttribute: z.string().optional(), - longDisplayNameAttribute: z.string().optional(), - shape: z.enum(SHAPE_STYLES).optional(), - backgroundOpacity: z.number().optional(), - borderWidth: z.number().optional(), - borderColor: z.string().optional(), - borderStyle: z.enum(LINE_STYLES).optional(), - }) - .transform( - ({ icon, ...rest }): Omit => - icon !== undefined ? { ...rest, iconUrl: icon } : rest, - ); +const conditionSchema = z.object({ + attribute: z.string(), + operator: z.enum(CONDITION_OPERATORS), + value: z.string(), + caseSensitive: z.boolean().optional(), +}); -const edgeEntrySchema = z.object({ +/** + * The vertex style fields shared by a base entry and its conditional style. The + * `icon`→`iconUrl` rename is applied by whichever schema embeds these fields. + * Loose `iconImageType` string matches storage — the upload seam fills it from + * the browser's `file.type`, so a fixed enum would reject valid uploads on + * round-trip. It is not a security boundary: the icon data is guarded by + * `safeIconValue`, and the only consumer that reads it does an exact match on + * `"image/svg+xml"` to choose the DOMPurify-sanitized inline-SVG render path. + */ +const vertexStyleFields = { + icon: safeIconValue.optional(), + iconImageType: z.string().optional(), + color: z.string().optional(), + displayLabel: z.string().optional(), + displayNameAttribute: z.string().optional(), + longDisplayNameAttribute: z.string().optional(), + shape: z.enum(SHAPE_STYLES).optional(), + backgroundOpacity: z.number().optional(), + borderWidth: z.number().optional(), + borderColor: z.string().optional(), + borderStyle: z.enum(LINE_STYLES).optional(), +}; + +const edgeStyleFields = { displayLabel: z.string().optional(), displayNameAttribute: z.string().optional(), labelColor: z.string().optional(), @@ -157,6 +160,41 @@ const edgeEntrySchema = z.object({ lineStyle: z.enum(LINE_STYLES).optional(), sourceArrowStyle: z.enum(ARROW_STYLES).optional(), targetArrowStyle: z.enum(ARROW_STYLES).optional(), +}; + +/** A vertex conditional style — the shared style fields plus a required condition. */ +const vertexConditionalStyleSchema = z + .object({ condition: conditionSchema, ...vertexStyleFields }) + .transform( + ({ icon, ...rest }): VertexConditionalStyle => + icon !== undefined ? { ...rest, iconUrl: icon } : rest, + ); + +const edgeConditionalStyleSchema = z.object({ + condition: conditionSchema, + ...edgeStyleFields, +}); + +/** + * One vertex entry. Unknown fields are stripped (Zod's default), so a file with + * extra keys imports without error and without storing them — in particular an + * injected `iconUrl` is dropped, never bypassing the `icon` allowlist, at both + * the top level and inside `conditionalStyle`. The `icon`→`iconUrl` rename to + * the storage model happens in `.transform()`, so it stays at this seam. + */ +const vertexEntrySchema = z + .object({ + ...vertexStyleFields, + conditionalStyle: vertexConditionalStyleSchema.optional(), + }) + .transform( + ({ icon, ...rest }): Omit => + icon !== undefined ? { ...rest, iconUrl: icon } : rest, + ); + +const edgeEntrySchema = z.object({ + ...edgeStyleFields, + conditionalStyle: edgeConditionalStyleSchema.optional(), }); // --- File-format types --- @@ -180,14 +218,28 @@ export type StylingExportPayload = { export function toVertexFileEntry( model: VertexStyleStorage, ): VertexStyleFileEntry { - const { type: _type, iconUrl, ...rest } = model; + const { type: _type, iconUrl, conditionalStyle, ...rest } = model; // The file format uses `icon`; storage uses `iconUrl`. Every other field maps - // straight across, so this rename is the only transformation on the way out. + // straight across, so this rename — applied at the top level and inside the + // conditional style — is the only transformation on the way out. + return { + ...rest, + ...(iconUrl !== undefined ? { icon: iconUrl } : {}), + ...(conditionalStyle !== undefined + ? { conditionalStyle: toVertexConditionalFileEntry(conditionalStyle) } + : {}), + }; +} + +function toVertexConditionalFileEntry(conditional: VertexConditionalStyle) { + const { iconUrl, ...rest } = conditional; return iconUrl !== undefined ? { ...rest, icon: iconUrl } : rest; } export function toEdgeFileEntry(model: EdgeStyleStorage): EdgeStyleFileEntry { const { type: _type, ...rest } = model; + // Edges have no `icon`↔`iconUrl` rename, so the conditional style (in `rest`) + // maps straight across. return rest; } diff --git a/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx b/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx index c36014407..f9e71b61e 100644 --- a/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx @@ -3,12 +3,15 @@ import { atom, useAtom, useSetAtom } from "jotai"; import { Button, ColorPopover, + ConditionBuilder, + createDefaultCondition, EdgePreview, Field, FieldGroup, FieldLabel, FieldLegend, FieldSet, + LabelledSetting, NumberInput, PreviewSurface, Select, @@ -16,6 +19,7 @@ import { SelectItem, SelectTrigger, SelectValue, + Switch, } from "@/components"; import { Dialog, @@ -35,6 +39,8 @@ import { import { type ArrowStyle, type LineStyle, + resolveConditionalEdgeStyle, + type StyleCondition, useEdgeStyling, } from "@/core/StateProvider/graphStyles"; import { useTextTransform } from "@/hooks"; @@ -42,6 +48,7 @@ import useTranslations from "@/hooks/useTranslations"; import { RESERVED_TYPES_PROPERTY } from "@/utils"; import { ARROW_STYLE_OPTIONS } from "./arrowsStyling"; +import { EdgeStyleFields, type EdgeStyleUpdate } from "./EdgeStyleFields"; import { LINE_STYLE_OPTIONS } from "./lineStyling"; const customizeEdgeTypeAtom = atom(undefined); @@ -97,6 +104,26 @@ function Content({ edgeType }: { edgeType: EdgeType }) { return options; })(); + const conditionalStyle = edgeStyle.conditionalStyle; + const resolvedConditional = resolveConditionalEdgeStyle(edgeStyle); + + const setConditionEnabled = (enabled: boolean) => + setEdgeStyle({ + conditionalStyle: enabled + ? { condition: createDefaultCondition(selectOptions) } + : undefined, + }); + + const updateCondition = (condition: StyleCondition) => { + if (!conditionalStyle) return; + setEdgeStyle({ conditionalStyle: { ...conditionalStyle, condition } }); + }; + + const updateConditionalStyle = (update: EdgeStyleUpdate) => { + if (!conditionalStyle) return; + setEdgeStyle({ conditionalStyle: { ...conditionalStyle, ...update } }); + }; + return (
@@ -106,7 +133,7 @@ function Content({ edgeType }: { edgeType: EdgeType }) { Changes here override the default style for this {t("edge-type")}. - +
Preview @@ -309,6 +336,33 @@ function Content({ edgeType }: { edgeType: EdgeType }) {
+ +
+ Conditional Style + + + + {conditionalStyle && resolvedConditional ? ( + <> + + + + ) : null} +
+ + { + if (file) { + uploadIcon(file); + } + }} + variant="outline" + > + + Upload + + + + + +
+ + {t("node")} Color + onChange({ color })} + /> + + + Background Opacity + + onChange({ backgroundOpacity }) + } + /> + +
+
+ + Border Color + + onChange({ borderColor: color }) + } + /> + + + Border Width + onChange({ borderWidth })} + /> + + + Border Style + + +
+
+ + ); +} diff --git a/packages/graph-explorer/src/modules/SearchSidebar/useEdgeAttributesAsScalars.test.ts b/packages/graph-explorer/src/modules/SearchSidebar/useEdgeAttributesAsScalars.test.ts index acddc5233..25f502155 100644 --- a/packages/graph-explorer/src/modules/SearchSidebar/useEdgeAttributesAsScalars.test.ts +++ b/packages/graph-explorer/src/modules/SearchSidebar/useEdgeAttributesAsScalars.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { createResultScalar } from "@/connector/entities"; import { + createEdge, createEdgeId, createEdgeType, createVertexId, @@ -38,6 +39,13 @@ describe("useEdgeAttributesAsScalars", () => { }, ] as DisplayAttribute[], hasUniqueId: true, + original: createEdge({ + id: "edge-1", + type: "knows", + sourceId: "vertex-1", + targetId: "vertex-2", + attributes: { since: "2020", weight: 0.8 }, + }), }; beforeEach(() => { diff --git a/packages/graph-explorer/src/modules/StyleImport/EdgeStyleImportCard.tsx b/packages/graph-explorer/src/modules/StyleImport/EdgeStyleImportCard.tsx index 4dcfd1eda..b006e9099 100644 --- a/packages/graph-explorer/src/modules/StyleImport/EdgeStyleImportCard.tsx +++ b/packages/graph-explorer/src/modules/StyleImport/EdgeStyleImportCard.tsx @@ -1,4 +1,5 @@ import { EdgePreview } from "@/components"; +import { formatStyleCondition } from "@/core/StateProvider/conditionalStyling"; import type { EdgeStyleImportItem } from "./styleImportPlan"; @@ -23,25 +24,47 @@ export function EdgeStyleImportCard({ selected: boolean; onToggle: () => void; }) { + const isConditional = item.variant === "conditional"; + return ( - +
- Before + {isConditional ? "Base" : "Before"}
- After + {isConditional ? "When met" : "After"}
- {item.type} - + + {item.type} + {isConditional ? ( + · conditional + ) : null} + + {isConditional ? ( + + ) : ( + + )}
); } diff --git a/packages/graph-explorer/src/modules/StyleImport/StyleImportModal.tsx b/packages/graph-explorer/src/modules/StyleImport/StyleImportModal.tsx index 773308246..e3f4e5dab 100644 --- a/packages/graph-explorer/src/modules/StyleImport/StyleImportModal.tsx +++ b/packages/graph-explorer/src/modules/StyleImport/StyleImportModal.tsx @@ -37,12 +37,13 @@ import { import { VertexStyleImportCard } from "./VertexStyleImportCard"; /** - * A stable per-item key that stays unique across the two type namespaces — - * a vertex and an edge can share a raw type string, so the `kind` prefix keeps - * their selection state distinct. + * A stable per-item key that stays unique across the two type namespaces and + * the base/conditional variants — a vertex and an edge can share a raw type + * string, and a type can contribute both a base and a conditional item, so the + * `kind` and `variant` prefixes keep their selection state distinct. */ function itemKey(item: StyleImportItem): string { - return `${item.kind}:${item.type}`; + return `${item.kind}:${item.variant}:${item.type}`; } const filterLabels: Record = { diff --git a/packages/graph-explorer/src/modules/StyleImport/VertexStyleImportCard.tsx b/packages/graph-explorer/src/modules/StyleImport/VertexStyleImportCard.tsx index 9fbc664b6..7062affcd 100644 --- a/packages/graph-explorer/src/modules/StyleImport/VertexStyleImportCard.tsx +++ b/packages/graph-explorer/src/modules/StyleImport/VertexStyleImportCard.tsx @@ -1,6 +1,7 @@ import { ArrowRightIcon } from "lucide-react"; import { VertexPreview } from "@/components"; +import { formatStyleCondition } from "@/core/StateProvider/conditionalStyling"; import type { VertexStyleImportItem } from "./styleImportPlan"; @@ -22,26 +23,50 @@ export function VertexStyleImportCard({ selected: boolean; onToggle: () => void; }) { + const isConditional = item.variant === "conditional"; + return ( - + - Before - - After + {isConditional ? "Base" : "Before"} + + {isConditional ? "When met" : "After"} + + - {item.type} - + + {item.type} + {isConditional ? ( + · conditional + ) : null} + + {isConditional ? ( + + ) : ( + + )} ); } diff --git a/packages/graph-explorer/src/modules/StyleImport/styleImportPlan.test.ts b/packages/graph-explorer/src/modules/StyleImport/styleImportPlan.test.ts index 1222075ae..6b36524f8 100644 --- a/packages/graph-explorer/src/modules/StyleImport/styleImportPlan.test.ts +++ b/packages/graph-explorer/src/modules/StyleImport/styleImportPlan.test.ts @@ -8,6 +8,8 @@ import type { StylingParseResult } from "@/core/styling"; import { createEdgeType, createVertexType } from "@/core/entities"; import { appDefaultVertexStyle, + resolveConditionalEdgeStyle, + resolveConditionalVertexStyle, resolveEdgeStyle, resolveVertexStyle, } from "@/core/StateProvider/graphStyles"; @@ -35,6 +37,7 @@ describe("buildStyleImportPlan", () => { expect(plan.items).toStrictEqual([ { kind: "vertex", + variant: "base", type, status: "new", incoming, @@ -59,6 +62,7 @@ describe("buildStyleImportPlan", () => { expect(plan.items).toStrictEqual([ { kind: "vertex", + variant: "base", type, status: "existing", incoming, @@ -110,6 +114,7 @@ describe("buildStyleImportPlan", () => { expect(plan.items).toStrictEqual([ { kind: "vertex", + variant: "base", type: changed, status: "new", incoming: changedIncoming, @@ -120,6 +125,51 @@ describe("buildStyleImportPlan", () => { expect(plan.skippedCount).toBe(1); }); + test("splits a type with a conditional style into base and conditional items", () => { + const type = createVertexType("Person"); + const condition = { + attribute: "known_bad", + operator: "=", + value: "true", + } as const; + const base: VertexStyleStorage = { type, color: "#abc" }; + const incoming: VertexStyleStorage = { + ...base, + conditionalStyle: { condition, color: "#f00" }, + }; + + const plan = buildStyleImportPlan( + parseResult(new Map([[type, incoming]])), + new Map(), + new Map(), + ); + + expect(plan.items).toStrictEqual([ + { + kind: "vertex", + variant: "base", + type, + status: "new", + incoming: base, + incomingStyle: resolveVertexStyle(type, base), + currentStyle: resolveVertexStyle(type), + }, + { + kind: "vertex", + variant: "conditional", + type, + status: "new", + condition, + incoming, + incomingStyle: resolveConditionalVertexStyle( + resolveVertexStyle(type, incoming), + )!.style, + currentStyle: resolveVertexStyle(type, base), + }, + ]); + expect(plan.skippedCount).toBe(0); + }); + test("includes edge styles alongside vertex styles", () => { const edgeType = createEdgeType("route"); const incoming: EdgeStyleStorage = { type: edgeType, lineColor: "#def" }; @@ -133,6 +183,7 @@ describe("buildStyleImportPlan", () => { expect(plan.items).toStrictEqual([ { kind: "edge", + variant: "base", type: edgeType, status: "new", incoming, @@ -141,4 +192,110 @@ describe("buildStyleImportPlan", () => { }, ]); }); + + test("splits an edge type with a conditional style into base and conditional items", () => { + const type = createEdgeType("route"); + const condition = { + attribute: "weight", + operator: ">", + value: "10", + } as const; + const base: EdgeStyleStorage = { type, lineColor: "#def" }; + const incoming: EdgeStyleStorage = { + ...base, + conditionalStyle: { condition, lineColor: "#f00" }, + }; + + const plan = buildStyleImportPlan( + parseResult(new Map(), new Map([[type, incoming]])), + new Map(), + new Map(), + ); + + expect(plan.items).toStrictEqual([ + { + kind: "edge", + variant: "base", + type, + status: "new", + incoming: base, + incomingStyle: resolveEdgeStyle(type, base), + currentStyle: resolveEdgeStyle(type), + }, + { + kind: "edge", + variant: "conditional", + type, + status: "new", + condition, + incoming, + incomingStyle: resolveConditionalEdgeStyle( + resolveEdgeStyle(type, incoming), + )!.style, + currentStyle: resolveEdgeStyle(type, base), + }, + ]); + expect(plan.skippedCount).toBe(0); + }); + + test("emits only the conditional item when the base matches but the condition is new", () => { + const type = createVertexType("Person"); + const condition = { + attribute: "known_bad", + operator: "=", + value: "true", + } as const; + const current: VertexStyleStorage = { type, color: "#abc" }; + // Same base color as current, so the base item is a no-op; only the + // condition is new. + const incoming: VertexStyleStorage = { + ...current, + conditionalStyle: { condition, color: "#f00" }, + }; + + const plan = buildStyleImportPlan( + parseResult(new Map([[type, incoming]])), + new Map([[type, current]]), + new Map(), + ); + + expect(plan.items).toStrictEqual([ + { + kind: "vertex", + variant: "conditional", + type, + status: "new", + condition, + incoming, + incomingStyle: resolveConditionalVertexStyle( + resolveVertexStyle(type, incoming), + )!.style, + currentStyle: resolveVertexStyle(type, current), + }, + ]); + expect(plan.skippedCount).toBe(0); + }); + + test("skips a type whose base and conditional both match the current style", () => { + const type = createVertexType("Person"); + const condition = { + attribute: "known_bad", + operator: "=", + value: "true", + } as const; + const style: VertexStyleStorage = { + type, + color: "#abc", + conditionalStyle: { condition, color: "#f00" }, + }; + + const plan = buildStyleImportPlan( + parseResult(new Map([[type, style]])), + new Map([[type, style]]), + new Map(), + ); + + expect(plan.items).toStrictEqual([]); + expect(plan.skippedCount).toBe(1); + }); }); diff --git a/packages/graph-explorer/src/modules/StyleImport/styleImportPlan.ts b/packages/graph-explorer/src/modules/StyleImport/styleImportPlan.ts index 645298ebf..8a5b8c0ee 100644 --- a/packages/graph-explorer/src/modules/StyleImport/styleImportPlan.ts +++ b/packages/graph-explorer/src/modules/StyleImport/styleImportPlan.ts @@ -4,12 +4,15 @@ import type { EdgeType, VertexType } from "@/core/entities"; import type { EdgeStyle, EdgeStyleStorage, + StyleCondition, VertexStyle, VertexStyleStorage, } from "@/core/StateProvider/graphStyles"; import type { StylingParseResult } from "@/core/styling"; import { + resolveConditionalEdgeStyle, + resolveConditionalVertexStyle, resolveEdgeStyle, resolveVertexStyle, } from "@/core/StateProvider/graphStyles"; @@ -21,12 +24,23 @@ import { */ export type StyleImportStatus = "new" | "existing"; +/** + * A type's conditional style is a separately selectable item so the user can + * import the base style without the condition (or vice versa). The `base` + * variant writes the entry without a condition; the `conditional` variant writes + * the full entry including the condition, so selecting it always brings the base + * along and no cross-item dependency is needed. + */ +export type StyleImportVariant = "base" | "conditional"; + /** * One loadable style, ready to render as a before→after card. `incoming` is the * storage entry that gets written on load; `incomingStyle`/`currentStyle` are - * the resolved styles the previews draw. A vertex and edge item are the same - * shape apart from their branded type and style types, so the discriminated - * `kind` keeps them in one list without losing type safety at the leaves. + * the resolved styles the previews draw (for a `conditional` item, the "before" + * is the base appearance and the "after" is the condition-met appearance). A + * vertex and edge item are the same shape apart from their branded type and + * style types, so the discriminated `kind` keeps them in one list without losing + * type safety at the leaves. */ export type VertexStyleImportItem = { kind: "vertex"; @@ -35,7 +49,10 @@ export type VertexStyleImportItem = { incoming: VertexStyleStorage; incomingStyle: VertexStyle; currentStyle: VertexStyle; -}; +} & ( + | { variant: "base" } + | { variant: "conditional"; condition: StyleCondition } +); export type EdgeStyleImportItem = { kind: "edge"; @@ -44,7 +61,10 @@ export type EdgeStyleImportItem = { incoming: EdgeStyleStorage; incomingStyle: EdgeStyle; currentStyle: EdgeStyle; -}; +} & ( + | { variant: "base" } + | { variant: "conditional"; condition: StyleCondition } +); export type StyleImportItem = VertexStyleImportItem | EdgeStyleImportItem; @@ -59,10 +79,23 @@ export type StyleImportPlan = { }; /** - * Turns a parsed styling file into the load plan: for each type, resolve the - * incoming and current styles, drop the ones that resolve identically (a no-op - * the user shouldn't have to decide about), and classify the rest as new or - * existing. Comparison is at the resolved level so a file that merely sets a + * A resolved style without its conditional block, so the base item's no-op + * check compares only the base appearance — a type whose base is unchanged but + * whose condition is new should still surface (as a conditional item). + */ +function baseAppearance( + style: S, +): Omit { + const { conditionalStyle: _drop, ...rest } = style; + return rest; +} + +/** + * Turns a parsed styling file into the load plan. Each type yields up to two + * items: a `base` item (the style without its condition) and, when the file + * carries one, a `conditional` item. Items that resolve identically to the + * current style are dropped; a type is counted as skipped only when it yields no + * items at all. Comparison is at the resolved level so a file that merely sets a * field to its existing effective value is skipped, regardless of how the two * storage partials happen to differ. */ @@ -76,38 +109,103 @@ export function buildStyleImportPlan( for (const [type, incoming] of parsed.vertexStyles) { const current = currentVertexStyles.get(type); - const incomingStyle = resolveVertexStyle(type, incoming); + const { conditionalStyle, ...baseIncoming } = incoming; + + const baseIncomingStyle = resolveVertexStyle(type, baseIncoming); const currentStyle = resolveVertexStyle(type, current); - if (isEqual(incomingStyle, currentStyle)) { + let emitted = false; + + if ( + !isEqual(baseAppearance(baseIncomingStyle), baseAppearance(currentStyle)) + ) { + items.push({ + kind: "vertex", + variant: "base", + type, + status: current ? "existing" : "new", + incoming: baseIncoming, + incomingStyle: baseIncomingStyle, + currentStyle, + }); + emitted = true; + } + + const incomingConditional = resolveConditionalVertexStyle( + resolveVertexStyle(type, incoming), + ); + if (conditionalStyle && incomingConditional) { + const currentConditionalStyle = current + ? resolveConditionalVertexStyle(resolveVertexStyle(type, current)) + ?.style + : undefined; + if (!isEqual(incomingConditional.style, currentConditionalStyle)) { + items.push({ + kind: "vertex", + variant: "conditional", + type, + status: current?.conditionalStyle ? "existing" : "new", + condition: conditionalStyle.condition, + incoming, + incomingStyle: incomingConditional.style, + currentStyle: baseIncomingStyle, + }); + emitted = true; + } + } + + if (!emitted) { skippedCount++; - continue; } - items.push({ - kind: "vertex", - type, - status: current ? "existing" : "new", - incoming, - incomingStyle, - currentStyle, - }); } for (const [type, incoming] of parsed.edgeStyles) { const current = currentEdgeStyles.get(type); - const incomingStyle = resolveEdgeStyle(type, incoming); + const { conditionalStyle, ...baseIncoming } = incoming; + + const baseIncomingStyle = resolveEdgeStyle(type, baseIncoming); const currentStyle = resolveEdgeStyle(type, current); - if (isEqual(incomingStyle, currentStyle)) { + let emitted = false; + + if ( + !isEqual(baseAppearance(baseIncomingStyle), baseAppearance(currentStyle)) + ) { + items.push({ + kind: "edge", + variant: "base", + type, + status: current ? "existing" : "new", + incoming: baseIncoming, + incomingStyle: baseIncomingStyle, + currentStyle, + }); + emitted = true; + } + + const incomingConditional = resolveConditionalEdgeStyle( + resolveEdgeStyle(type, incoming), + ); + if (conditionalStyle && incomingConditional) { + const currentConditionalStyle = current + ? resolveConditionalEdgeStyle(resolveEdgeStyle(type, current))?.style + : undefined; + if (!isEqual(incomingConditional.style, currentConditionalStyle)) { + items.push({ + kind: "edge", + variant: "conditional", + type, + status: current?.conditionalStyle ? "existing" : "new", + condition: conditionalStyle.condition, + incoming, + incomingStyle: incomingConditional.style, + currentStyle: baseIncomingStyle, + }); + emitted = true; + } + } + + if (!emitted) { skippedCount++; - continue; } - items.push({ - kind: "edge", - type, - status: current ? "existing" : "new", - incoming, - incomingStyle, - currentStyle, - }); } return { items, skippedCount }; diff --git a/packages/graph-explorer/src/modules/StyleImport/styleImportView.test.ts b/packages/graph-explorer/src/modules/StyleImport/styleImportView.test.ts index 659947d90..ad567498c 100644 --- a/packages/graph-explorer/src/modules/StyleImport/styleImportView.test.ts +++ b/packages/graph-explorer/src/modules/StyleImport/styleImportView.test.ts @@ -25,6 +25,7 @@ function vertexItem( const incoming = { type }; return { kind: "vertex", + variant: "base", type, status, incoming, @@ -41,6 +42,7 @@ function edgeItem( const incoming = { type }; return { kind: "edge", + variant: "base", type, status, incoming, diff --git a/packages/graph-explorer/src/modules/StyleImport/useApplyStyleImport.test.ts b/packages/graph-explorer/src/modules/StyleImport/useApplyStyleImport.test.ts index 10d18ed73..d54fbd542 100644 --- a/packages/graph-explorer/src/modules/StyleImport/useApplyStyleImport.test.ts +++ b/packages/graph-explorer/src/modules/StyleImport/useApplyStyleImport.test.ts @@ -27,6 +27,7 @@ function vertexItem( ): StyleImportItem { return { kind: "vertex", + variant: "base", type, status: "new", incoming, @@ -54,6 +55,7 @@ describe("useApplyStyleImport", () => { vertexItem(vertexType, vertexIncoming), { kind: "edge", + variant: "base", type: edgeType, status: "new", incoming: edgeIncoming, @@ -91,6 +93,49 @@ describe("useApplyStyleImport", () => { }); }); + test("a selected conditional item writes the full entry including the condition", () => { + const type = createVertexType("Person"); + const base: VertexStyleStorage = { type, color: "#abc" }; + const full: VertexStyleStorage = { + ...base, + conditionalStyle: { + condition: { attribute: "known_bad", operator: "=", value: "true" }, + color: "#f00", + }, + }; + + const { result } = renderHookWithJotai(() => useApplyStyleImport()); + result.current([ + vertexItem(type, base), + { + kind: "vertex", + variant: "conditional", + type, + status: "new", + condition: { attribute: "known_bad", operator: "=", value: "true" }, + incoming: full, + incomingStyle: resolveVertexStyle(type, full), + currentStyle: resolveVertexStyle(type, base), + }, + ]); + + expect(getAppStore().get(userVertexStylesAtom).get(type)).toStrictEqual( + full, + ); + }); + + test("the base item alone writes the entry without the condition", () => { + const type = createVertexType("Beacon"); + const base: VertexStyleStorage = { type, color: "#abc" }; + + const { result } = renderHookWithJotai(() => useApplyStyleImport()); + result.current([vertexItem(type, base)]); + + expect(getAppStore().get(userVertexStylesAtom).get(type)).toStrictEqual( + base, + ); + }); + test("leaves unselected types untouched", () => { const kept = createVertexType("Country"); const store = getAppStore(); diff --git a/packages/graph-explorer/src/modules/StyleImport/useApplyStyleImport.ts b/packages/graph-explorer/src/modules/StyleImport/useApplyStyleImport.ts index b08af933e..4f2a155b3 100644 --- a/packages/graph-explorer/src/modules/StyleImport/useApplyStyleImport.ts +++ b/packages/graph-explorer/src/modules/StyleImport/useApplyStyleImport.ts @@ -1,5 +1,11 @@ import { useSetAtom } from "jotai"; +import type { EdgeType, VertexType } from "@/core/entities"; +import type { + EdgeStyleStorage, + VertexStyleStorage, +} from "@/core/StateProvider/graphStyles"; + import { userEdgeStylesAtom, userVertexStylesAtom, @@ -8,34 +14,57 @@ import { import type { StyleImportItem } from "./styleImportPlan"; /** - * Writes the chosen styles into the user styles layer. Each item replaces its - * type's entry wholesale (full-type replacement, not a per-field merge), and - * types absent from the selection are left untouched. Vertices and edges are - * split into one write per atom so a mixed selection still lands atomically. + * Collapses the selected items for one entity kind into the entry to write per + * type. A type can contribute both a `base` and a `conditional` item; the + * conditional entry already includes the base fields plus the condition, so it + * supersedes the base item and there is no cross-item dependency to enforce. + */ +function composeEntries( + items: { type: T; variant: StyleImportItem["variant"]; incoming: S }[], +): Map { + const byType = new Map(); + for (const item of items) { + if (!byType.has(item.type) || item.variant === "conditional") { + byType.set(item.type, item.incoming); + } + } + return byType; +} + +/** + * Writes the chosen styles into the user styles layer. Each type's selected + * items are composed into a single entry that replaces its type's entry + * wholesale (full-type replacement, not a per-field merge); types absent from + * the selection are left untouched. Vertices and edges are split into one write + * per atom so a mixed selection still lands atomically. */ export function useApplyStyleImport() { const setUserVertexStyles = useSetAtom(userVertexStylesAtom); const setUserEdgeStyles = useSetAtom(userEdgeStylesAtom); return function applyStyleImport(items: StyleImportItem[]): void { - const vertexItems = items.filter(item => item.kind === "vertex"); - const edgeItems = items.filter(item => item.kind === "edge"); + const vertexEntries = composeEntries( + items.filter(item => item.kind === "vertex"), + ); + const edgeEntries = composeEntries( + items.filter(item => item.kind === "edge"), + ); - if (vertexItems.length > 0) { + if (vertexEntries.size > 0) { setUserVertexStyles(prev => { const next = new Map(prev); - for (const item of vertexItems) { - next.set(item.type, item.incoming); + for (const [type, incoming] of vertexEntries) { + next.set(type, incoming); } return next; }); } - if (edgeItems.length > 0) { + if (edgeEntries.size > 0) { setUserEdgeStyles(prev => { const next = new Map(prev); - for (const item of edgeItems) { - next.set(item.type, item.incoming); + for (const [type, incoming] of edgeEntries) { + next.set(type, incoming); } return next; });