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
22 changes: 21 additions & 1 deletion packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -105,7 +122,10 @@ export default function SchemaGraph({ className, ...props }: SchemaGraphProps) {
</Panel>
</PanelGroup>

<SchemaExplorerSidebar selection={selection} />
<SchemaExplorerSidebar
selection={selection}
onSelectionChange={handleSidebarSelectionChange}
/>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -14,6 +22,22 @@ vi.mock("@/hooks", async () => {
};
});

vi.mock("@/core", async () => {
const actual = await vi.importActual<typeof Core>("@/core");
return {
...actual,
useDisplayEdgeTypeConfig: (edgeType: string) => ({
displayLabel: edgeType,
attributes: [],
}),
useDisplayVertexTypeConfig: (vertexType: string) => ({
displayLabel: vertexType,
attributes: [],
}),
useVertexPreferences: () => ({ color: "#000000" }),
};
});

function createAttribute(
overrides: Partial<DisplayConfigAttribute> = {},
): DisplayConfigAttribute {
Expand Down Expand Up @@ -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(<EdgeConnectionRow edgeConnection={createEdgeConnection()} />);

expect(screen.queryAllByRole("button")).toHaveLength(0);
});

test("renders three buttons (source, edge, target) when onSelectionChange is provided", () => {
render(
<EdgeConnectionRow
edgeConnection={createEdgeConnection()}
onSelectionChange={() => {}}
/>,
);

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(
<EdgeConnectionRow
edgeConnection={createEdgeConnection()}
onSelectionChange={onSelectionChange}
/>,
);

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(
<EdgeConnectionRow
edgeConnection={createEdgeConnection()}
onSelectionChange={onSelectionChange}
/>,
);

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(
<EdgeConnectionRow
edgeConnection={createEdgeConnection()}
onSelectionChange={onSelectionChange}
/>,
);

await user.click(screen.getByRole("button", { name: /knows/ }));

expect(onSelectionChange).toHaveBeenCalledWith({
type: "edge-connection",
id: "Person-[knows]->Company",
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
toHumanString,
} from "@/components";
import {
createEdgeConnectionId,
type DisplayConfigAttribute,
type EdgeConnection,
type EdgeType,
Expand All @@ -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 <div className={cn("space-y-3", className)} {...props} />;
Expand Down Expand Up @@ -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 (
<p
className={cn("text-muted-foreground text-base/7", className)}
Expand All @@ -140,52 +173,95 @@ export function EdgeConnectionRow({
<VertexTypeText
vertexType={edgeConnection.sourceVertexType}
selected={selectedVertexType === edgeConnection.sourceVertexType}
onClick={handleSourceClick}
/>
<EdgeTypeText
edgeType={edgeConnection.edgeType}
selected={selectedVertexType == null}
onClick={handleEdgeClick}
/>
<VertexTypeText
vertexType={edgeConnection.targetVertexType}
selected={selectedVertexType === edgeConnection.targetVertexType}
onClick={handleTargetClick}
/>
</p>
);
}

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 (
<button
type="button"
className={cn(baseClass, interactiveTextStyles)}
data-selected={dataSelected}
onClick={onClick}
>
{labelText}
</button>
);
}

return (
<span
className="data-selected:text-text-primary italic data-selected:font-bold"
data-selected={selected ? true : undefined}
>
{`${ASCII.NBSP}${ASCII.RARR} ${displayLabel}${ASCII.NBSP}${ASCII.RARR} `}
<span className={baseClass} data-selected={dataSelected}>
{labelText}
</span>
);
}

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 (
<button
type="button"
className={cn(baseClass, interactiveTextStyles)}
data-selected={dataSelected}
style={inlineStyle}
onClick={onClick}
>
{displayLabel}
</button>
);
}

return (
<span
className="data-selected:text-text-primary underline decoration-2 underline-offset-4 data-selected:font-bold"
data-selected={selected ? true : undefined}
style={{ textDecorationColor: style.color }}
className={baseClass}
data-selected={dataSelected}
style={inlineStyle}
>
{displayLabel}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
import { useTranslations } from "@/hooks";
import { LABELS } from "@/utils";

import type { SchemaGraphSelectionItem } from "../SchemaGraph";

import {
Details,
DetailsHeader,
Expand All @@ -28,11 +30,13 @@ import { SchemaDiscoveryAlert } from "./SchemaDiscoveryAlert";

export type EdgeConnectionDetailsProps = {
edgeConnection: EdgeConnection;
onSelectionChange?: (item: SchemaGraphSelectionItem) => void;
} & ComponentPropsWithRef<typeof Panel>;

/** Displays detailed information about an edge connection including properties and total count */
export function EdgeConnectionDetails({
edgeConnection,
onSelectionChange,
...props
}: EdgeConnectionDetailsProps) {
const t = useTranslations();
Expand Down Expand Up @@ -66,7 +70,10 @@ export function EdgeConnectionDetails({
<DetailsHeader>
<DetailsTitle>{t("edge-connection")}</DetailsTitle>
</DetailsHeader>
<EdgeConnectionRow edgeConnection={edgeConnection} />
<EdgeConnectionRow
edgeConnection={edgeConnection}
onSelectionChange={onSelectionChange}
/>
</Details>

<PropertiesDetails attributes={config.attributes} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
import { useTranslations } from "@/hooks";
import { LABELS } from "@/utils";

import type { SchemaGraphSelectionItem } from "../SchemaGraph";

import {
Details,
DetailsHeader,
Expand All @@ -30,11 +32,13 @@ import { SchemaDiscoveryAlert } from "./SchemaDiscoveryAlert";

export type NodeLabelDetailsProps = {
vertexType: VertexType;
onSelectionChange?: (item: SchemaGraphSelectionItem) => void;
} & ComponentPropsWithRef<typeof Panel>;

/** Displays detailed information about a vertex type including properties and edge connections */
export function NodeLabelDetails({
vertexType,
onSelectionChange,
...props
}: NodeLabelDetailsProps) {
const t = useTranslations();
Expand Down Expand Up @@ -76,6 +80,7 @@ export function NodeLabelDetails({
<EdgeConnectionRow
edgeConnection={edgeConnection}
selectedVertexType={vertexType}
onSelectionChange={onSelectionChange}
/>
</li>
)) ?? (
Expand Down
Loading