From 3495bca7f5cbb9d4efd8ca7daa4830f0c7e7c650 Mon Sep 17 00:00:00 2001 From: somi <79901950+1wos@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:21:28 +0900 Subject: [PATCH] Add clickable links for node types and edge types in schema explorer details panel --- .../src/modules/SchemaGraph/SchemaGraph.tsx | 22 +++- .../SchemaGraph/Sidebar/Details.test.tsx | 111 +++++++++++++++++- .../modules/SchemaGraph/Sidebar/Details.tsx | 92 +++++++++++++-- .../Sidebar/EdgeConnectionDetails.tsx | 9 +- .../SchemaGraph/Sidebar/NodeLabelDetails.tsx | 5 + .../Sidebar/SchemaDetailsContent.tsx | 20 +++- .../Sidebar/SchemaExplorerSidebar.tsx | 12 +- 7 files changed, 253 insertions(+), 18 deletions(-) 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({ - +