diff --git a/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx b/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx
index cbe7f7d82..bef096a76 100644
--- a/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx
+++ b/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx
@@ -75,6 +75,23 @@ export default function SchemaGraph({ className, ...props }: SchemaGraphProps) {
}
};
+ const handleSidebarSelectionChange = (item: SchemaGraphSelectionItem) => {
+ setSelection(item);
+ setGraphSelection(
+ item.type === "vertex-type"
+ ? {
+ nodeIds: new Set([item.id]),
+ edgeIds: new Set(),
+ groupIds: new Set(),
+ }
+ : {
+ nodeIds: new Set(),
+ edgeIds: new Set([item.id]),
+ groupIds: new Set(),
+ },
+ );
+ };
+
const hasSchemaData = nodes.length > 0;
return (
@@ -105,7 +122,10 @@ export default function SchemaGraph({ className, ...props }: SchemaGraphProps) {
-
+
);
}
diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.test.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.test.tsx
index 8211da824..1e2df592b 100644
--- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.test.tsx
+++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.test.tsx
@@ -1,10 +1,18 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
-import { describe, expect, test } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, test, vi } from "vitest";
-import type { DisplayConfigAttribute } from "@/core";
+import type * as Core from "@/core";
-import { PropertiesDetails } from "./Details";
+import {
+ createEdgeType,
+ createVertexType,
+ type DisplayConfigAttribute,
+ type EdgeConnection,
+} from "@/core";
+
+import { EdgeConnectionRow, PropertiesDetails } from "./Details";
vi.mock("@/hooks", async () => {
const actual = await vi.importActual("@/hooks");
@@ -14,6 +22,22 @@ vi.mock("@/hooks", async () => {
};
});
+vi.mock("@/core", async () => {
+ const actual = await vi.importActual("@/core");
+ return {
+ ...actual,
+ useDisplayEdgeTypeConfig: (edgeType: string) => ({
+ displayLabel: edgeType,
+ attributes: [],
+ }),
+ useDisplayVertexTypeConfig: (vertexType: string) => ({
+ displayLabel: vertexType,
+ attributes: [],
+ }),
+ useVertexPreferences: () => ({ color: "#000000" }),
+ };
+});
+
function createAttribute(
overrides: Partial = {},
): DisplayConfigAttribute {
@@ -70,3 +94,84 @@ describe("PropertiesDetails", () => {
expect(screen.getByText("3")).toBeInTheDocument();
});
});
+
+describe("EdgeConnectionRow", () => {
+ function createEdgeConnection(): EdgeConnection {
+ return {
+ sourceVertexType: createVertexType("Person"),
+ edgeType: createEdgeType("knows"),
+ targetVertexType: createVertexType("Company"),
+ };
+ }
+
+ test("renders text without buttons when onSelectionChange is not provided", () => {
+ render();
+
+ expect(screen.queryAllByRole("button")).toHaveLength(0);
+ });
+
+ test("renders three buttons (source, edge, target) when onSelectionChange is provided", () => {
+ render(
+ {}}
+ />,
+ );
+
+ expect(screen.getAllByRole("button")).toHaveLength(3);
+ });
+
+ test("calls onSelectionChange with the source vertex when source button is clicked", async () => {
+ const user = userEvent.setup();
+ const onSelectionChange = vi.fn();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Person" }));
+
+ expect(onSelectionChange).toHaveBeenCalledWith({
+ type: "vertex-type",
+ id: "Person",
+ });
+ });
+
+ test("calls onSelectionChange with the target vertex when target button is clicked", async () => {
+ const user = userEvent.setup();
+ const onSelectionChange = vi.fn();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Company" }));
+
+ expect(onSelectionChange).toHaveBeenCalledWith({
+ type: "vertex-type",
+ id: "Company",
+ });
+ });
+
+ test("calls onSelectionChange with the edge connection id when edge button is clicked", async () => {
+ const user = userEvent.setup();
+ const onSelectionChange = vi.fn();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /knows/ }));
+
+ expect(onSelectionChange).toHaveBeenCalledWith({
+ type: "edge-connection",
+ id: "Person-[knows]->Company",
+ });
+ });
+});
diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.tsx
index 8d83e27d6..bb07e382e 100644
--- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.tsx
+++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/Details.tsx
@@ -12,6 +12,7 @@ import {
toHumanString,
} from "@/components";
import {
+ createEdgeConnectionId,
type DisplayConfigAttribute,
type EdgeConnection,
type EdgeType,
@@ -23,6 +24,8 @@ import {
import { useTranslations } from "@/hooks";
import { ASCII, cn } from "@/utils";
+import type { SchemaGraphSelectionItem } from "../SchemaGraph";
+
/** Container for a detail section with consistent vertical spacing. */
export function Details({ className, ...props }: ComponentPropsWithRef<"div">) {
return ;
@@ -122,16 +125,46 @@ export function PropertiesDetails({
/**
* Renders an edge connection as "SourceType → EdgeType → TargetType" with the
* selected vertex type highlighted.
+ *
+ * When `onSelectionChange` is provided, each vertex type and edge type becomes a
+ * clickable button that invokes `onSelectionChange` with the corresponding selection
+ * item, allowing the parent to update the schema graph selection.
*/
export function EdgeConnectionRow({
selectedVertexType,
edgeConnection,
+ onSelectionChange,
className,
...props
}: ComponentPropsWithRef<"div"> & {
selectedVertexType?: VertexType;
edgeConnection: EdgeConnection;
+ onSelectionChange?: (item: SchemaGraphSelectionItem) => void;
}) {
+ const handleSourceClick = onSelectionChange
+ ? () =>
+ onSelectionChange({
+ type: "vertex-type",
+ id: edgeConnection.sourceVertexType,
+ })
+ : undefined;
+
+ const handleTargetClick = onSelectionChange
+ ? () =>
+ onSelectionChange({
+ type: "vertex-type",
+ id: edgeConnection.targetVertexType,
+ })
+ : undefined;
+
+ const handleEdgeClick = onSelectionChange
+ ? () =>
+ onSelectionChange({
+ type: "edge-connection",
+ id: createEdgeConnectionId(edgeConnection),
+ })
+ : undefined;
+
return (
);
}
+const interactiveTextStyles =
+ "cursor-pointer bg-transparent p-0 font-[inherit] hover:text-text-primary focus-visible:ring-ring focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
+
function EdgeTypeText({
selected,
edgeType,
+ onClick,
}: {
edgeType: EdgeType;
selected: boolean;
+ onClick?: () => void;
}) {
const { displayLabel } = useDisplayEdgeTypeConfig(edgeType);
+ const labelText = `${ASCII.NBSP}${ASCII.RARR} ${displayLabel}${ASCII.NBSP}${ASCII.RARR} `;
+ const baseClass =
+ "data-selected:text-text-primary italic data-selected:font-bold";
+ const dataSelected = selected ? true : undefined;
+
+ if (onClick) {
+ return (
+
+ );
+ }
+
return (
-
- {`${ASCII.NBSP}${ASCII.RARR} ${displayLabel}${ASCII.NBSP}${ASCII.RARR} `}
+
+ {labelText}
);
}
@@ -174,18 +230,38 @@ function EdgeTypeText({
function VertexTypeText({
selected,
vertexType,
+ onClick,
}: {
vertexType: VertexType;
selected: boolean;
+ onClick?: () => void;
}) {
const style = useVertexPreferences(vertexType);
const { displayLabel } = useDisplayVertexTypeConfig(vertexType);
+ const baseClass =
+ "data-selected:text-text-primary underline decoration-2 underline-offset-4 data-selected:font-bold";
+ const dataSelected = selected ? true : undefined;
+ const inlineStyle = { textDecorationColor: style.color };
+
+ if (onClick) {
+ return (
+
+ );
+ }
return (
{displayLabel}
diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/EdgeConnectionDetails.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/EdgeConnectionDetails.tsx
index 25fd50418..dca152600 100644
--- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/EdgeConnectionDetails.tsx
+++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/EdgeConnectionDetails.tsx
@@ -16,6 +16,8 @@ import {
import { useTranslations } from "@/hooks";
import { LABELS } from "@/utils";
+import type { SchemaGraphSelectionItem } from "../SchemaGraph";
+
import {
Details,
DetailsHeader,
@@ -28,11 +30,13 @@ import { SchemaDiscoveryAlert } from "./SchemaDiscoveryAlert";
export type EdgeConnectionDetailsProps = {
edgeConnection: EdgeConnection;
+ onSelectionChange?: (item: SchemaGraphSelectionItem) => void;
} & ComponentPropsWithRef;
/** Displays detailed information about an edge connection including properties and total count */
export function EdgeConnectionDetails({
edgeConnection,
+ onSelectionChange,
...props
}: EdgeConnectionDetailsProps) {
const t = useTranslations();
@@ -66,7 +70,10 @@ export function EdgeConnectionDetails({
{t("edge-connection")}
-
+
diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/NodeLabelDetails.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/NodeLabelDetails.tsx
index a26886be7..02d4bcdd7 100644
--- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/NodeLabelDetails.tsx
+++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/NodeLabelDetails.tsx
@@ -18,6 +18,8 @@ import {
import { useTranslations } from "@/hooks";
import { LABELS } from "@/utils";
+import type { SchemaGraphSelectionItem } from "../SchemaGraph";
+
import {
Details,
DetailsHeader,
@@ -30,11 +32,13 @@ import { SchemaDiscoveryAlert } from "./SchemaDiscoveryAlert";
export type NodeLabelDetailsProps = {
vertexType: VertexType;
+ onSelectionChange?: (item: SchemaGraphSelectionItem) => void;
} & ComponentPropsWithRef;
/** Displays detailed information about a vertex type including properties and edge connections */
export function NodeLabelDetails({
vertexType,
+ onSelectionChange,
...props
}: NodeLabelDetailsProps) {
const t = useTranslations();
@@ -76,6 +80,7 @@ export function NodeLabelDetails({
)) ?? (
diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaDetailsContent.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaDetailsContent.tsx
index 984c36e08..2da0aba8a 100644
--- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaDetailsContent.tsx
+++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaDetailsContent.tsx
@@ -10,17 +10,24 @@ import { useGraphSchema } from "@/core";
import { useTranslations } from "@/hooks";
import { LABELS } from "@/utils";
-import type { SchemaGraphSelection } from "../SchemaGraph";
+import type {
+ SchemaGraphSelection,
+ SchemaGraphSelectionItem,
+} from "../SchemaGraph";
import { EdgeConnectionDetails } from "./EdgeConnectionDetails";
import { NodeLabelDetails } from "./NodeLabelDetails";
export type SchemaDetailsContentProps = {
selection: SchemaGraphSelection;
+ onSelectionChange?: (item: SchemaGraphSelectionItem) => void;
};
/** Displays details for selected vertex type or edge connection in schema graph */
-export function SchemaDetailsContent({ selection }: SchemaDetailsContentProps) {
+export function SchemaDetailsContent({
+ selection,
+ onSelectionChange,
+}: SchemaDetailsContentProps) {
const t = useTranslations();
const graphSchema = useGraphSchema();
@@ -59,7 +66,13 @@ export function SchemaDetailsContent({ selection }: SchemaDetailsContentProps) {
}
if (selection.type === "vertex-type") {
- return ;
+ return (
+
+ );
}
const edgeConnection = graphSchema.edgeConnections.byEdgeConnectionId.get(
@@ -70,6 +83,7 @@ export function SchemaDetailsContent({ selection }: SchemaDetailsContentProps) {
return (
);
diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.tsx
index f01d758ea..128b9a971 100644
--- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.tsx
+++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.tsx
@@ -7,7 +7,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/Tooltip";
import { useTranslations } from "@/hooks";
import { cn, LABELS } from "@/utils";
-import type { SchemaGraphSelection } from "../SchemaGraph";
+import type {
+ SchemaGraphSelection,
+ SchemaGraphSelectionItem,
+} from "../SchemaGraph";
import { SchemaDetailsContent } from "./SchemaDetailsContent";
import { SchemaEdgesStyling } from "./SchemaEdgesStyling";
@@ -19,11 +22,13 @@ import { SchemaNodesStyling } from "./SchemaNodesStyling";
export type SchemaExplorerSidebarProps = {
selection: SchemaGraphSelection;
+ onSelectionChange?: (item: SchemaGraphSelectionItem) => void;
};
/** Resizable sidebar for schema graph with details, node styling, and edge styling tabs */
export function SchemaExplorerSidebar({
selection,
+ onSelectionChange,
}: SchemaExplorerSidebarProps) {
const t = useTranslations();
const [activeTab, setActiveTab] = useState("details");
@@ -57,7 +62,10 @@ export function SchemaExplorerSidebar({
-
+