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

Filter by extension

Filter by extension

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

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

Expand Down
2 changes: 2 additions & 0 deletions docs/features/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<type> · 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.
Expand Down
92 changes: 92 additions & 0 deletions packages/graph-explorer/src/components/ConditionBuilder.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ConditionBuilder
condition={{ attribute: "score", operator: ">", 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(
<ConditionBuilder
condition={{ attribute: "score", operator: ">", 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(
<ConditionBuilder
condition={{ attribute: "score", operator: "=", value: "10" }}
attributeOptions={[{ label: "Score", value: "score" }]}
onChange={vi.fn()}
/>,
);

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(
<ConditionBuilder
condition={{ attribute: "score", operator: "matches", value: "Jo*" }}
attributeOptions={[{ label: "Score", value: "score" }]}
onChange={onChange}
/>,
);

fireEvent.click(screen.getByRole("checkbox", { name: /case sensitive/i }));

expect(onChange).toHaveBeenCalledWith({
attribute: "score",
operator: "matches",
value: "Jo*",
caseSensitive: false,
});
});
});
118 changes: 118 additions & 0 deletions packages/graph-explorer/src/components/ConditionBuilder.tsx
Original file line number Diff line number Diff line change
@@ -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<ConditionOperator>([
"=",
"!=",
"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 (
<div className="grid grid-cols-3 gap-4">
<Field>
<FieldLabel>Attribute</FieldLabel>
<Select
value={condition.attribute}
onValueChange={value => onChange({ ...condition, attribute: value })}
>
<SelectTrigger>
<SelectValue placeholder="Choose attribute" />
</SelectTrigger>
<SelectContent>
{attributeOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Operator</FieldLabel>
<Select
value={condition.operator}
onValueChange={value =>
onChange({ ...condition, operator: value as ConditionOperator })
}
>
<SelectTrigger>
<SelectValue placeholder="Choose operator" />
</SelectTrigger>
<SelectContent>
{CONDITION_OPERATORS.map(operator => (
<SelectItem key={operator} value={operator}>
{CONDITION_OPERATOR_LABELS[operator]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Value</FieldLabel>
<Input
value={condition.value}
onChange={event =>
onChange({ ...condition, value: event.target.value })
}
/>
{CASE_SENSITIVITY_OPERATORS.has(condition.operator) ? (
<FieldLabel className="flex-row items-center text-sm font-normal">
<Checkbox
checked={condition.caseSensitive !== false}
onCheckedChange={checked =>
onChange({ ...condition, caseSensitive: checked === true })
}
aria-label="Case sensitive"
/>
Case sensitive
</FieldLabel>
) : null}
</Field>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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"),
);
});
});
2 changes: 2 additions & 0 deletions packages/graph-explorer/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export * from "./SettingsPage";

export * from "./SidebarTabs";

export * from "./ConditionBuilder";

export * from "./Switch";

export * from "./TextArea";
Expand Down
Loading