fix(acadenice): include parent-child edges in graph + space-scope view — R4.6
Graph view was empty for users who built page hierarchies (sub-pages) but had not placed any wikilinks. The graph service now queries pages.parent_page_id as a second edge source (type: parent_child) and merges it with acadenice_backlink edges, so hierarchy-only workspaces display meaningful graphs immediately. - dto: added parent_child to LinkType enum; added slug field to GraphNode - service: loadParentChildEdges (permission-filtered SQL), parallel merge with loadEdges - controller: always appends parent_child to the effective type list - client: graph-client.ts typed for parent_child and slug; graph-canvas renders dashed grey lines for parent_child vs solid brand lines for wikilinks; legend with aria-label; title/slug/untitled fallback chain for node labels - space sidebar: Graph menu item -> /s/:spaceSlug/graph - new route: /s/:spaceSlug/graph -> SpaceGraph page (injects spaceId filter) - i18n: en-US + fr-FR keys for legend and space graph - tests: 42 server + 59 client, all green; 10 new R4.6 tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8e717401bd
commit
60d64822e4
16 changed files with 497 additions and 54 deletions
|
|
@ -1139,6 +1139,12 @@
|
||||||
"graph.untitled_page": "(untitled)",
|
"graph.untitled_page": "(untitled)",
|
||||||
"graph.error_title": "Graph load error",
|
"graph.error_title": "Graph load error",
|
||||||
"graph.error_generic": "Could not load the knowledge graph",
|
"graph.error_generic": "Could not load the knowledge graph",
|
||||||
|
"graph.legend_title": "Legend",
|
||||||
|
"graph.legend_wikilink": "Wikilink",
|
||||||
|
"graph.legend_parent_child": "Parent-child",
|
||||||
|
"graph.legend_aria_label": "Edge type legend: solid line = wikilink, dashed line = parent-child hierarchy",
|
||||||
|
"graph.space_graph_title": "Graph — {{spaceName}}",
|
||||||
|
"graph.space_graph_menu_label": "Graph",
|
||||||
"templates.page_title": "Templates",
|
"templates.page_title": "Templates",
|
||||||
"templates.create_button": "New template",
|
"templates.create_button": "New template",
|
||||||
"templates.create_title": "Create template",
|
"templates.create_title": "Create template",
|
||||||
|
|
|
||||||
|
|
@ -1094,6 +1094,12 @@
|
||||||
"graph.untitled_page": "(sans titre)",
|
"graph.untitled_page": "(sans titre)",
|
||||||
"graph.error_title": "Erreur de chargement du graphe",
|
"graph.error_title": "Erreur de chargement du graphe",
|
||||||
"graph.error_generic": "Impossible de charger le graphe de connaissance",
|
"graph.error_generic": "Impossible de charger le graphe de connaissance",
|
||||||
|
"graph.legend_title": "Legende",
|
||||||
|
"graph.legend_wikilink": "Wikilink",
|
||||||
|
"graph.legend_parent_child": "Parent-enfant",
|
||||||
|
"graph.legend_aria_label": "Legende des types de liens : ligne pleine = wikilink, ligne pointillee = hierarchie parent-enfant",
|
||||||
|
"graph.space_graph_title": "Graphe — {{spaceName}}",
|
||||||
|
"graph.space_graph_menu_label": "Graphe",
|
||||||
"templates.page_title": "Modeles",
|
"templates.page_title": "Modeles",
|
||||||
"templates.create_button": "Nouveau modele",
|
"templates.create_button": "Nouveau modele",
|
||||||
"templates.create_title": "Creer un modele",
|
"templates.create_title": "Creer un modele",
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,12 @@ import AcadeniceNotificationsPage from "@/features/acadenice/notifications/pages
|
||||||
import NotificationPreferencesPage from "@/features/acadenice/notifications/pages/notification-preferences-page";
|
import NotificationPreferencesPage from "@/features/acadenice/notifications/pages/notification-preferences-page";
|
||||||
// Acadenice R4.3 — Web Clipper token management
|
// Acadenice R4.3 — Web Clipper token management
|
||||||
import ClipperTokensPage from "@/features/acadenice/clipper/pages/clipper-tokens-page";
|
import ClipperTokensPage from "@/features/acadenice/clipper/pages/clipper-tokens-page";
|
||||||
|
// Acadenice R4.5 — EE replacement: audit log, API keys, OIDC security status
|
||||||
|
import AcadeniceAuditLogPage from "@/features/acadenice/audit-log/pages/audit-log-page";
|
||||||
|
import AcadeniceApiKeysPage from "@/features/acadenice/api-keys/pages/api-keys-page";
|
||||||
|
import AcadeniceSecurityPage from "@/features/acadenice/oidc-status/pages/security-page";
|
||||||
|
// Acadenice R4.6 — space-scoped graph view
|
||||||
|
import SpaceGraph from "@/pages/space/space-graph";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -118,6 +124,8 @@ export default function App() {
|
||||||
/>
|
/>
|
||||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||||
|
{/* Acadenice R4.6 — space-scoped graph view */}
|
||||||
|
<Route path={"/s/:spaceSlug/graph"} element={<SpaceGraph />} />
|
||||||
<Route
|
<Route
|
||||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||||
element={<Page />}
|
element={<Page />}
|
||||||
|
|
@ -159,6 +167,10 @@ export default function App() {
|
||||||
<Route path={"clipper-tokens"} element={<ClipperTokensPage />} />
|
<Route path={"clipper-tokens"} element={<ClipperTokensPage />} />
|
||||||
{/* Acadenice R4.4 — Workspace branding */}
|
{/* Acadenice R4.4 — Workspace branding */}
|
||||||
<Route path={"branding"} element={<WorkspaceBranding />} />
|
<Route path={"branding"} element={<WorkspaceBranding />} />
|
||||||
|
{/* Acadenice R4.5 — open source EE replacements */}
|
||||||
|
<Route path={"acadenice/audit-log"} element={<AcadeniceAuditLogPage />} />
|
||||||
|
<Route path={"acadenice/api-keys"} element={<AcadeniceApiKeysPage />} />
|
||||||
|
<Route path={"acadenice/security"} element={<AcadeniceSecurityPage />} />
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
@ -25,6 +25,7 @@ const NODES: GraphNode[] = [
|
||||||
{
|
{
|
||||||
id: "p1",
|
id: "p1",
|
||||||
label: "Page 1",
|
label: "Page 1",
|
||||||
|
slug: "page-1",
|
||||||
type: "page",
|
type: "page",
|
||||||
spaceId: "s1",
|
spaceId: "s1",
|
||||||
spaceName: "Engineering",
|
spaceName: "Engineering",
|
||||||
|
|
@ -35,6 +36,7 @@ const NODES: GraphNode[] = [
|
||||||
{
|
{
|
||||||
id: "p2",
|
id: "p2",
|
||||||
label: "Page 2",
|
label: "Page 2",
|
||||||
|
slug: null,
|
||||||
type: "page",
|
type: "page",
|
||||||
spaceId: "s1",
|
spaceId: "s1",
|
||||||
spaceName: "Engineering",
|
spaceName: "Engineering",
|
||||||
|
|
@ -54,6 +56,16 @@ const EDGES: GraphEdge[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const PARENT_CHILD_EDGES: GraphEdge[] = [
|
||||||
|
{
|
||||||
|
id: "p1:p2:parent_child",
|
||||||
|
source: "p1",
|
||||||
|
target: "p2",
|
||||||
|
type: "parent_child",
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function renderCanvas(nodes = NODES, edges = EDGES) {
|
function renderCanvas(nodes = NODES, edges = EDGES) {
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
return render(
|
return render(
|
||||||
|
|
@ -107,6 +119,7 @@ describe("GraphCanvas smoke", () => {
|
||||||
const orphan: GraphNode = {
|
const orphan: GraphNode = {
|
||||||
id: "orphan-1",
|
id: "orphan-1",
|
||||||
label: "Orphan",
|
label: "Orphan",
|
||||||
|
slug: "orphan-1",
|
||||||
type: "page",
|
type: "page",
|
||||||
spaceId: "s2",
|
spaceId: "s2",
|
||||||
spaceName: null,
|
spaceName: null,
|
||||||
|
|
@ -121,6 +134,7 @@ describe("GraphCanvas smoke", () => {
|
||||||
const noLabel: GraphNode = {
|
const noLabel: GraphNode = {
|
||||||
id: "p3",
|
id: "p3",
|
||||||
label: null,
|
label: null,
|
||||||
|
slug: null,
|
||||||
type: "page",
|
type: "page",
|
||||||
spaceId: "s1",
|
spaceId: "s1",
|
||||||
spaceName: "Test",
|
spaceName: "Test",
|
||||||
|
|
@ -130,4 +144,41 @@ describe("GraphCanvas smoke", () => {
|
||||||
};
|
};
|
||||||
expect(() => renderCanvas([noLabel], [])).not.toThrow();
|
expect(() => renderCanvas([noLabel], [])).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// R4.6 — parent_child edge type
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("renders parent_child edges without error", () => {
|
||||||
|
expect(() => renderCanvas(NODES, PARENT_CHILD_EDGES)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders mixed wikilink and parent_child edges without error", () => {
|
||||||
|
expect(() =>
|
||||||
|
renderCanvas(NODES, [...EDGES, ...PARENT_CHILD_EDGES])
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nodes with slug fallback when label is null", () => {
|
||||||
|
const slugOnlyNode: GraphNode = {
|
||||||
|
id: "p-slug",
|
||||||
|
label: null,
|
||||||
|
slug: "my-page-slug",
|
||||||
|
type: "page",
|
||||||
|
spaceId: "s1",
|
||||||
|
spaceName: "Test",
|
||||||
|
icon: null,
|
||||||
|
isOrphan: false,
|
||||||
|
metrics: { inDegree: 0, outDegree: 1 },
|
||||||
|
};
|
||||||
|
// Should mount without throw — slug used as display label in canvas
|
||||||
|
expect(() => renderCanvas([slugOnlyNode], [])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders legend elements in DOM (the outer wrapper div is present)", () => {
|
||||||
|
const { container } = renderCanvas(NODES, EDGES);
|
||||||
|
// The wrapper div is always rendered regardless of lib availability.
|
||||||
|
// We verify the component mounts a root element (canvas or placeholder).
|
||||||
|
expect(container.firstChild).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ const MOCK_NODES = [
|
||||||
{
|
{
|
||||||
id: "p1",
|
id: "p1",
|
||||||
label: "Page 1",
|
label: "Page 1",
|
||||||
|
slug: "page-1",
|
||||||
type: "page" as const,
|
type: "page" as const,
|
||||||
spaceId: "s1",
|
spaceId: "s1",
|
||||||
spaceName: "Engineering",
|
spaceName: "Engineering",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ vi.mock("react-i18next", () => ({
|
||||||
const MOCK_NODE: GraphNode = {
|
const MOCK_NODE: GraphNode = {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
label: "My Page",
|
label: "My Page",
|
||||||
|
slug: "my-page",
|
||||||
type: "page",
|
type: "page",
|
||||||
spaceId: "s1",
|
spaceId: "s1",
|
||||||
spaceName: "Engineering",
|
spaceName: "Engineering",
|
||||||
|
|
@ -94,20 +95,35 @@ describe("GraphSidePanel", () => {
|
||||||
expect(screen.queryByText(/orphan/i)).toBeNull();
|
expect(screen.queryByText(/orphan/i)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("open-page button is disabled when slugMap has no entry", () => {
|
it("open-page button is disabled when both node.slug and slugMap have no entry", () => {
|
||||||
renderPanel(MOCK_NODE, {}, true);
|
// Use a node without slug and without slugMap entry
|
||||||
|
const nodeNoSlug: GraphNode = { ...MOCK_NODE, slug: null };
|
||||||
|
renderPanel(nodeNoSlug, {}, true);
|
||||||
const btn = screen.getByText("graph.open_page").closest("button");
|
const btn = screen.getByText("graph.open_page").closest("button");
|
||||||
expect(btn?.hasAttribute("disabled") || btn?.getAttribute("data-disabled")).toBeTruthy();
|
expect(btn?.hasAttribute("disabled") || btn?.getAttribute("data-disabled")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("open-page button is enabled when slugMap has entry", () => {
|
it("open-page button is enabled when node.slug is set", () => {
|
||||||
renderPanel(MOCK_NODE, { p1: "my-page-slug" }, true);
|
// MOCK_NODE has slug: "my-page"
|
||||||
|
renderPanel(MOCK_NODE, {}, true);
|
||||||
const btn = screen.getByText("graph.open_page").closest("button");
|
const btn = screen.getByText("graph.open_page").closest("button");
|
||||||
expect(btn?.hasAttribute("disabled")).toBeFalsy();
|
expect(btn?.hasAttribute("disabled")).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders untitled label when node.label is null", () => {
|
it("open-page button is enabled when slugMap has entry even if node.slug is null", () => {
|
||||||
renderPanel({ ...MOCK_NODE, label: null }, {}, true);
|
const nodeNoSlug: GraphNode = { ...MOCK_NODE, slug: null };
|
||||||
|
renderPanel(nodeNoSlug, { p1: "my-page-slug" }, true);
|
||||||
|
const btn = screen.getByText("graph.open_page").closest("button");
|
||||||
|
expect(btn?.hasAttribute("disabled")).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders slug when node.label is null but slug is set", () => {
|
||||||
|
renderPanel({ ...MOCK_NODE, label: null, slug: "my-page" }, {}, true);
|
||||||
|
expect(screen.getByText("my-page")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders untitled label when both node.label and node.slug are null", () => {
|
||||||
|
renderPanel({ ...MOCK_NODE, label: null, slug: null }, {}, true);
|
||||||
expect(screen.getByText("graph.untitled_page")).toBeTruthy();
|
expect(screen.getByText("graph.untitled_page")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ import React, {
|
||||||
useRef,
|
useRef,
|
||||||
Suspense,
|
Suspense,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useMantineTheme, useMantineColorScheme } from "@mantine/core";
|
import { useMantineTheme, useMantineColorScheme, Group, Text, Box } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { GraphNode, GraphEdge } from "../services/graph-client";
|
import { GraphNode, GraphEdge } from "../services/graph-client";
|
||||||
import {
|
import {
|
||||||
|
|
@ -39,6 +40,7 @@ import {
|
||||||
interface ForceGraphNode {
|
interface ForceGraphNode {
|
||||||
id: string;
|
id: string;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
|
slug: string | null;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
spaceName: string | null;
|
spaceName: string | null;
|
||||||
isOrphan: boolean;
|
isOrphan: boolean;
|
||||||
|
|
@ -52,7 +54,7 @@ interface ForceGraphNode {
|
||||||
interface ForceGraphLink {
|
interface ForceGraphLink {
|
||||||
source: string | ForceGraphNode;
|
source: string | ForceGraphNode;
|
||||||
target: string | ForceGraphNode;
|
target: string | ForceGraphNode;
|
||||||
edgeType: "wikilink" | "mention" | "database_embed";
|
edgeType: "wikilink" | "mention" | "database_embed" | "parent_child";
|
||||||
weight: number;
|
weight: number;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +73,7 @@ const EDGE_COLORS: Record<string, string> = {
|
||||||
wikilink: "#4dabf7",
|
wikilink: "#4dabf7",
|
||||||
mention: "#51cf66",
|
mention: "#51cf66",
|
||||||
database_embed: "#ff922b",
|
database_embed: "#ff922b",
|
||||||
|
parent_child: "#868e96",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Log-scale node radius proportional to in+out degree (min 4, max 18). */
|
/** Log-scale node radius proportional to in+out degree (min 4, max 18). */
|
||||||
|
|
@ -185,6 +188,7 @@ export function GraphCanvas({
|
||||||
height,
|
height,
|
||||||
slugMap = {},
|
slugMap = {},
|
||||||
}: GraphCanvasProps) {
|
}: GraphCanvasProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -200,6 +204,7 @@ export function GraphCanvas({
|
||||||
nodes.map((n) => ({
|
nodes.map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
label: n.label,
|
label: n.label,
|
||||||
|
slug: n.slug,
|
||||||
spaceId: n.spaceId,
|
spaceId: n.spaceId,
|
||||||
spaceName: n.spaceName,
|
spaceName: n.spaceName,
|
||||||
isOrphan: n.isOrphan,
|
isOrphan: n.isOrphan,
|
||||||
|
|
@ -311,9 +316,9 @@ export function GraphCanvas({
|
||||||
: dimmed ? "#444" : spaceColor(node.spaceId);
|
: dimmed ? "#444" : spaceColor(node.spaceId);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// Label visible at sufficient zoom
|
// Label visible at sufficient zoom — fallback chain: title > slug > untitled
|
||||||
if (globalScale >= 1.5) {
|
if (globalScale >= 1.5) {
|
||||||
const label = node.label ?? "(untitled)";
|
const label = node.label ?? node.slug ?? t("graph.untitled_page");
|
||||||
const fontSize = Math.max(6 / globalScale, 10 / globalScale);
|
const fontSize = Math.max(6 / globalScale, 10 / globalScale);
|
||||||
ctx.font = `${fontSize}px sans-serif`;
|
ctx.font = `${fontSize}px sans-serif`;
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
|
|
@ -326,7 +331,7 @@ export function GraphCanvas({
|
||||||
ctx.fillText(label, x, y + r + 2 / globalScale);
|
ctx.fillText(label, x, y + r + 2 / globalScale);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedNodeId, neighborIds, matchIds, colorScheme],
|
[selectedNodeId, neighborIds, matchIds, colorScheme, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ---- Link color ---- */
|
/* ---- Link color ---- */
|
||||||
|
|
@ -334,9 +339,9 @@ export function GraphCanvas({
|
||||||
const linkColor = useCallback(
|
const linkColor = useCallback(
|
||||||
(link: ForceGraphLink) => {
|
(link: ForceGraphLink) => {
|
||||||
const s = typeof link.source === "object" ? link.source.id : link.source;
|
const s = typeof link.source === "object" ? link.source.id : link.source;
|
||||||
const t = typeof link.target === "object" ? link.target.id : link.target;
|
const tgt = typeof link.target === "object" ? link.target.id : link.target;
|
||||||
const dimmed = Boolean(
|
const dimmed = Boolean(
|
||||||
selectedNodeId && s !== selectedNodeId && t !== selectedNodeId,
|
selectedNodeId && s !== selectedNodeId && tgt !== selectedNodeId,
|
||||||
);
|
);
|
||||||
const base = EDGE_COLORS[link.edgeType] ?? "#adb5bd";
|
const base = EDGE_COLORS[link.edgeType] ?? "#adb5bd";
|
||||||
return dimmed ? "#3a3a3a" : base;
|
return dimmed ? "#3a3a3a" : base;
|
||||||
|
|
@ -344,6 +349,15 @@ export function GraphCanvas({
|
||||||
[selectedNodeId],
|
[selectedNodeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ---- Link line dash (dashed for parent_child) ---- */
|
||||||
|
|
||||||
|
const linkLineDash = useCallback(
|
||||||
|
(link: ForceGraphLink): number[] | null => {
|
||||||
|
return link.edgeType === "parent_child" ? [3, 3] : null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
/* ---- Interaction handlers ---- */
|
/* ---- Interaction handlers ---- */
|
||||||
|
|
||||||
const handleNodeClick = useCallback(
|
const handleNodeClick = useCallback(
|
||||||
|
|
@ -357,7 +371,9 @@ export function GraphCanvas({
|
||||||
|
|
||||||
const handleNodeDoubleClick = useCallback(
|
const handleNodeDoubleClick = useCallback(
|
||||||
(node: ForceGraphNode) => {
|
(node: ForceGraphNode) => {
|
||||||
const slugId = slugMap[node.id];
|
// Prefer slug from the node (populated by the server since R4.6).
|
||||||
|
// Fall back to slugMap for backward compatibility.
|
||||||
|
const slugId = node.slug ?? slugMap[node.id];
|
||||||
if (slugId) navigate(`/p/${slugId}`);
|
if (slugId) navigate(`/p/${slugId}`);
|
||||||
},
|
},
|
||||||
[slugMap, navigate],
|
[slugMap, navigate],
|
||||||
|
|
@ -383,6 +399,7 @@ export function GraphCanvas({
|
||||||
const ForceGraph2D = ForceGraph2DLazy;
|
const ForceGraph2D = ForceGraph2DLazy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div style={{ position: "relative", width, height }}>
|
||||||
<Suspense fallback={<GraphPlaceholder width={width} height={height} />}>
|
<Suspense fallback={<GraphPlaceholder width={width} height={height} />}>
|
||||||
<ForceGraph2D
|
<ForceGraph2D
|
||||||
ref={(ref: ForceGraphInstance | null) => {
|
ref={(ref: ForceGraphInstance | null) => {
|
||||||
|
|
@ -395,6 +412,7 @@ export function GraphCanvas({
|
||||||
nodeCanvasObject={paintNode}
|
nodeCanvasObject={paintNode}
|
||||||
nodeCanvasObjectMode={() => "replace"}
|
nodeCanvasObjectMode={() => "replace"}
|
||||||
linkColor={linkColor}
|
linkColor={linkColor}
|
||||||
|
linkLineDash={linkLineDash}
|
||||||
linkWidth={(link: ForceGraphLink) =>
|
linkWidth={(link: ForceGraphLink) =>
|
||||||
Math.min(0.5 + (link.weight - 1) * 0.3, 3)
|
Math.min(0.5 + (link.weight - 1) * 0.3, 3)
|
||||||
}
|
}
|
||||||
|
|
@ -404,10 +422,53 @@ export function GraphCanvas({
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
onNodeRightClick={handleNodeRightClick}
|
onNodeRightClick={handleNodeRightClick}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
nodeLabel={(node: ForceGraphNode) => node.label ?? "(untitled)"}
|
nodeLabel={(node: ForceGraphNode) =>
|
||||||
|
node.label ?? node.slug ?? t("graph.untitled_page")
|
||||||
|
}
|
||||||
enableNodeDrag
|
enableNodeDrag
|
||||||
cooldownTicks={200}
|
cooldownTicks={200}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Edge type legend — accessible via aria-label */}
|
||||||
|
<Box
|
||||||
|
aria-label={t("graph.legend_aria_label")}
|
||||||
|
role="complementary"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 12,
|
||||||
|
left: 12,
|
||||||
|
background: "var(--mantine-color-body)",
|
||||||
|
border: "1px solid var(--mantine-color-default-border)",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "6px 10px",
|
||||||
|
fontSize: 11,
|
||||||
|
opacity: 0.9,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="xs" fw={600} mb={4} c="dimmed">
|
||||||
|
{t("graph.legend_title")}
|
||||||
|
</Text>
|
||||||
|
<Group gap={10}>
|
||||||
|
<Group gap={4} align="center">
|
||||||
|
<svg width={24} height={8} aria-hidden="true">
|
||||||
|
<line x1={0} y1={4} x2={24} y2={4} stroke="#4dabf7" strokeWidth={2} />
|
||||||
|
</svg>
|
||||||
|
<Text size="xs">{t("graph.legend_wikilink")}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={4} align="center">
|
||||||
|
<svg width={24} height={8} aria-hidden="true">
|
||||||
|
<line
|
||||||
|
x1={0} y1={4} x2={24} y2={4}
|
||||||
|
stroke="#868e96" strokeWidth={2}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<Text size="xs">{t("graph.legend_parent_child")}</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,8 @@ export function GraphSidePanel({ node, slugMap }: GraphSidePanelProps) {
|
||||||
|
|
||||||
function openPage() {
|
function openPage() {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const slugId = slugMap[node.id];
|
// Prefer slug from the node (populated server-side since R4.6).
|
||||||
|
const slugId = node.slug ?? slugMap[node.id];
|
||||||
if (slugId) navigate(`/p/${slugId}`);
|
if (slugId) navigate(`/p/${slugId}`);
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +58,7 @@ export function GraphSidePanel({ node, slugMap }: GraphSidePanelProps) {
|
||||||
<Stack gap="sm" p="md">
|
<Stack gap="sm" p="md">
|
||||||
<Group justify="space-between" align="flex-start">
|
<Group justify="space-between" align="flex-start">
|
||||||
<Text fw={600} size="sm" style={{ flex: 1 }} lineClamp={3}>
|
<Text fw={600} size="sm" style={{ flex: 1 }} lineClamp={3}>
|
||||||
{node.label ?? t("graph.untitled_page")}
|
{node.label ?? node.slug ?? t("graph.untitled_page")}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
|
@ -96,7 +97,7 @@ export function GraphSidePanel({ node, slugMap }: GraphSidePanelProps) {
|
||||||
size="xs"
|
size="xs"
|
||||||
leftSection={<IconExternalLink size={14} />}
|
leftSection={<IconExternalLink size={14} />}
|
||||||
onClick={openPage}
|
onClick={openPage}
|
||||||
disabled={!slugMap[node.id]}
|
disabled={!node.slug && !slugMap[node.id]}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
{t("graph.open_page")}
|
{t("graph.open_page")}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import api from "@/lib/api-client";
|
||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
id: string;
|
id: string;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
|
/** URL slug for /p/:slug navigation — null when the page has no slug yet */
|
||||||
|
slug: string | null;
|
||||||
type: "page";
|
type: "page";
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
spaceName: string | null;
|
spaceName: string | null;
|
||||||
|
|
@ -25,7 +27,7 @@ export interface GraphEdge {
|
||||||
id: string;
|
id: string;
|
||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
type: "wikilink" | "mention" | "database_embed";
|
type: "wikilink" | "mention" | "database_embed" | "parent_child";
|
||||||
weight: number;
|
weight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
IconFileExport,
|
IconFileExport,
|
||||||
IconHome,
|
IconHome,
|
||||||
|
IconNetwork,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
|
|
@ -349,6 +350,16 @@ function SpaceMenu({
|
||||||
{isWatching ? t("Stop watching space") : t("Watch space")}
|
{isWatching ? t("Stop watching space") : t("Watch space")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
{/* Acadenice R4.6 — space graph view */}
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
to={`/s/${spaceSlug}/graph`}
|
||||||
|
leftSection={<IconNetwork size={16} />}
|
||||||
|
aria-label={t("graph.space_graph_menu_label")}
|
||||||
|
>
|
||||||
|
{t("graph.space_graph_menu_label")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
{canManagePages && (
|
{canManagePages && (
|
||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
|
||||||
49
apps/client/src/pages/space/space-graph.tsx
Normal file
49
apps/client/src/pages/space/space-graph.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* Space-scoped graph page (R4.6).
|
||||||
|
*
|
||||||
|
* Route: /s/:spaceSlug/graph
|
||||||
|
*
|
||||||
|
* Renders the full GraphPage component but pre-filters to the current space.
|
||||||
|
* The spaceId is injected into the graph filters atom on mount so the backend
|
||||||
|
* only returns nodes belonging to this space.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
import { getAppName } from "@/lib/config.ts";
|
||||||
|
import { graphFiltersAtom } from "@/features/acadenice/graph/hooks/use-graph-controls";
|
||||||
|
import GraphPage from "@/features/acadenice/graph/pages/graph-page";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function SpaceGraph() {
|
||||||
|
const { spaceSlug } = useParams<{ spaceSlug: string }>();
|
||||||
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
const setFilters = useSetAtom(graphFiltersAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Inject spaceId filter on mount; restore to null on unmount so the
|
||||||
|
// workspace-level graph is unaffected when navigating away.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!space?.id) return;
|
||||||
|
setFilters((prev) => ({ ...prev, spaceId: space.id }));
|
||||||
|
return () => {
|
||||||
|
setFilters((prev) => ({ ...prev, spaceId: null }));
|
||||||
|
};
|
||||||
|
}, [space?.id, setFilters]);
|
||||||
|
|
||||||
|
const pageTitle = space
|
||||||
|
? `${t("graph.space_graph_title", { spaceName: space.name })} - ${getAppName()}`
|
||||||
|
: `${t("graph.page_title")} - ${getAppName()}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>{pageTitle}</title>
|
||||||
|
</Helmet>
|
||||||
|
<GraphPage />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -45,12 +45,18 @@ export class GraphController {
|
||||||
|
|
||||||
const q = parsed.data;
|
const q = parsed.data;
|
||||||
|
|
||||||
|
// Always include parent_child so pages linked via sub-page hierarchy are
|
||||||
|
// visible even when the user has not created any wikilinks.
|
||||||
|
const types = q.types.includes('parent_child')
|
||||||
|
? q.types
|
||||||
|
: [...q.types, 'parent_child' as const];
|
||||||
|
|
||||||
return this.graphService.buildGraph({
|
return this.graphService.buildGraph({
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
spaceId: q.spaceId,
|
spaceId: q.spaceId,
|
||||||
pageId: q.pageId,
|
pageId: q.pageId,
|
||||||
depth: q.depth,
|
depth: q.depth,
|
||||||
types: q.types,
|
types,
|
||||||
includeOrphans: q.includeOrphans,
|
includeOrphans: q.includeOrphans,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { z } from 'zod';
|
||||||
// Query parameter schema
|
// Query parameter schema
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const LINK_TYPES = ['wikilink', 'mention', 'database_embed'] as const;
|
const LINK_TYPES = ['wikilink', 'mention', 'database_embed', 'parent_child'] as const;
|
||||||
export type LinkType = (typeof LINK_TYPES)[number];
|
export type LinkType = (typeof LINK_TYPES)[number];
|
||||||
|
|
||||||
export const GraphQuerySchema = z.object({
|
export const GraphQuerySchema = z.object({
|
||||||
|
|
@ -31,12 +31,15 @@ export const GraphQuerySchema = z.object({
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((val) => {
|
.transform((val) => {
|
||||||
if (!val) return [...LINK_TYPES];
|
// Default types exclude parent_child so the filter stays backward-compat.
|
||||||
|
// parent_child must be requested explicitly (or via the default all-types path).
|
||||||
|
const DEFAULT_QUERY_TYPES: LinkType[] = ['wikilink', 'mention', 'database_embed'];
|
||||||
|
if (!val) return DEFAULT_QUERY_TYPES;
|
||||||
const parts = val.split(',').map((s) => s.trim()) as LinkType[];
|
const parts = val.split(',').map((s) => s.trim()) as LinkType[];
|
||||||
const valid = parts.filter((p): p is LinkType =>
|
const valid = parts.filter((p): p is LinkType =>
|
||||||
(LINK_TYPES as readonly string[]).includes(p),
|
(LINK_TYPES as readonly string[]).includes(p),
|
||||||
);
|
);
|
||||||
return valid.length > 0 ? valid : [...LINK_TYPES];
|
return valid.length > 0 ? valid : DEFAULT_QUERY_TYPES;
|
||||||
}),
|
}),
|
||||||
includeOrphans: z
|
includeOrphans: z
|
||||||
.string()
|
.string()
|
||||||
|
|
@ -55,6 +58,8 @@ export interface GraphNode {
|
||||||
id: string;
|
id: string;
|
||||||
/** Page title (may be null for untitled pages) */
|
/** Page title (may be null for untitled pages) */
|
||||||
label: string | null;
|
label: string | null;
|
||||||
|
/** URL slug for /p/:slug navigation — null when the page has no slug */
|
||||||
|
slug: string | null;
|
||||||
type: 'page';
|
type: 'page';
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
spaceName: string | null;
|
spaceName: string | null;
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ interface BacklinkAggRow {
|
||||||
interface PageMetaRow {
|
interface PageMetaRow {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
slug: string | null;
|
||||||
space_id: string;
|
space_id: string;
|
||||||
space_name: string | null;
|
space_name: string | null;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
|
|
@ -178,7 +179,24 @@ export class GraphService {
|
||||||
|
|
||||||
// Step 1: Load all permission-filtered edges for the workspace (optionally
|
// Step 1: Load all permission-filtered edges for the workspace (optionally
|
||||||
// constrained to a space). This is a single aggregated SQL query.
|
// constrained to a space). This is a single aggregated SQL query.
|
||||||
const rawEdges = await this.loadEdges(workspaceId, userId, spaceId, types);
|
// Wikilink/mention/database_embed edges come from acadenice_backlink.
|
||||||
|
// parent_child edges come from pages.parent_page_id (always included when
|
||||||
|
// the effective type list contains 'parent_child').
|
||||||
|
const backlinkTypes = types.filter(
|
||||||
|
(t): t is 'wikilink' | 'mention' | 'database_embed' => t !== 'parent_child',
|
||||||
|
);
|
||||||
|
const includeParentChild = types.includes('parent_child');
|
||||||
|
|
||||||
|
const [backlinkEdges, parentChildEdges] = await Promise.all([
|
||||||
|
backlinkTypes.length > 0
|
||||||
|
? this.loadEdges(workspaceId, userId, spaceId, backlinkTypes)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
includeParentChild
|
||||||
|
? this.loadParentChildEdges(workspaceId, userId, spaceId)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rawEdges: BacklinkAggRow[] = [...backlinkEdges, ...parentChildEdges];
|
||||||
|
|
||||||
// Step 2: If pageId is specified, run BFS to keep only reachable nodes.
|
// Step 2: If pageId is specified, run BFS to keep only reachable nodes.
|
||||||
let filteredEdges: BacklinkAggRow[];
|
let filteredEdges: BacklinkAggRow[];
|
||||||
|
|
@ -291,6 +309,7 @@ export class GraphService {
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id,
|
id,
|
||||||
label: meta.title,
|
label: meta.title,
|
||||||
|
slug: meta.slug,
|
||||||
type: 'page',
|
type: 'page',
|
||||||
spaceId: meta.space_id,
|
spaceId: meta.space_id,
|
||||||
spaceName: meta.space_name,
|
spaceName: meta.space_name,
|
||||||
|
|
@ -337,7 +356,7 @@ export class GraphService {
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
spaceId: string | undefined,
|
spaceId: string | undefined,
|
||||||
types: LinkType[],
|
types: Array<'wikilink' | 'mention' | 'database_embed'>,
|
||||||
): Promise<BacklinkAggRow[]> {
|
): Promise<BacklinkAggRow[]> {
|
||||||
if (types.length === 0) return [];
|
if (types.length === 0) return [];
|
||||||
|
|
||||||
|
|
@ -415,6 +434,7 @@ export class GraphService {
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
p.title,
|
p.title,
|
||||||
|
p.slug,
|
||||||
p.space_id,
|
p.space_id,
|
||||||
sp.name AS space_name,
|
sp.name AS space_name,
|
||||||
p.icon
|
p.icon
|
||||||
|
|
@ -459,6 +479,7 @@ export class GraphService {
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
p.title,
|
p.title,
|
||||||
|
p.slug,
|
||||||
p.space_id,
|
p.space_id,
|
||||||
sp.name AS space_name,
|
sp.name AS space_name,
|
||||||
p.icon
|
p.icon
|
||||||
|
|
@ -479,6 +500,7 @@ export class GraphService {
|
||||||
WHERE bl.source_page_id = p.id
|
WHERE bl.source_page_id = p.id
|
||||||
OR bl.target_page_id = p.id
|
OR bl.target_page_id = p.id
|
||||||
)
|
)
|
||||||
|
AND p.parent_page_id IS NULL
|
||||||
ORDER BY p.title ASC
|
ORDER BY p.title ASC
|
||||||
`.execute(this.db);
|
`.execute(this.db);
|
||||||
|
|
||||||
|
|
@ -489,6 +511,65 @@ export class GraphService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load parent-child edges from pages.parent_page_id.
|
||||||
|
*
|
||||||
|
* Each row where parent_page_id IS NOT NULL produces an edge from the parent
|
||||||
|
* page to the child page with type='parent_child' and weight=1.
|
||||||
|
* Permission filter: both parent and child must be readable by userId.
|
||||||
|
*/
|
||||||
|
private async loadParentChildEdges(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
spaceId: string | undefined,
|
||||||
|
): Promise<BacklinkAggRow[]> {
|
||||||
|
try {
|
||||||
|
const spaceFilter = spaceId
|
||||||
|
? sql`AND child_sp.id = ${spaceId}`
|
||||||
|
: sql``;
|
||||||
|
|
||||||
|
const rows = await sql<BacklinkAggRow>`
|
||||||
|
SELECT
|
||||||
|
child.parent_page_id AS source_page_id,
|
||||||
|
child.id AS target_page_id,
|
||||||
|
'parent_child'::text AS link_type,
|
||||||
|
1::int AS weight
|
||||||
|
FROM pages child
|
||||||
|
-- Child page space
|
||||||
|
JOIN spaces child_sp ON child_sp.id = child.space_id
|
||||||
|
-- Parent page
|
||||||
|
JOIN pages parent ON parent.id = child.parent_page_id
|
||||||
|
-- Parent page space
|
||||||
|
JOIN spaces parent_sp ON parent_sp.id = parent.space_id
|
||||||
|
WHERE child_sp.workspace_id = ${workspaceId}
|
||||||
|
AND child.deleted_at IS NULL
|
||||||
|
AND parent.deleted_at IS NULL
|
||||||
|
-- Permission: child page readable
|
||||||
|
AND (
|
||||||
|
child_sp.visibility = 'public'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM space_members sm
|
||||||
|
WHERE sm.space_id = child_sp.id AND sm.user_id = ${userId}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
-- Permission: parent page readable
|
||||||
|
AND (
|
||||||
|
parent_sp.visibility = 'public'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM space_members sm
|
||||||
|
WHERE sm.space_id = parent_sp.id AND sm.user_id = ${userId}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
${spaceFilter}
|
||||||
|
`.execute(this.db);
|
||||||
|
|
||||||
|
return rows.rows;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`loadParentChildEdges failed: ${err?.['message']}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// BFS (iterative, depth-limited)
|
// BFS (iterative, depth-limited)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ describe('GraphController', () => {
|
||||||
{
|
{
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
label: 'Page 1',
|
label: 'Page 1',
|
||||||
|
slug: 'page-1',
|
||||||
type: 'page',
|
type: 'page',
|
||||||
spaceId: 'sp-1',
|
spaceId: 'sp-1',
|
||||||
spaceName: 'Space',
|
spaceName: 'Space',
|
||||||
|
|
@ -197,13 +198,21 @@ describe('GraphController', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies all three types by default when types param is absent', async () => {
|
it('always includes parent_child in the types passed to the service', async () => {
|
||||||
await controller.getGraph({}, mockUser, mockWorkspace);
|
await controller.getGraph({}, mockUser, mockWorkspace);
|
||||||
|
|
||||||
expect(service.buildGraph).toHaveBeenCalledWith(
|
expect(service.buildGraph).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
types: expect.arrayContaining(['wikilink', 'mention', 'database_embed']),
|
types: expect.arrayContaining(['wikilink', 'mention', 'database_embed', 'parent_child']),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not duplicate parent_child when explicitly requested', async () => {
|
||||||
|
await controller.getGraph({ types: 'wikilink,parent_child' }, mockUser, mockWorkspace);
|
||||||
|
|
||||||
|
const callArgs = (service.buildGraph as jest.Mock).mock.calls[0][0];
|
||||||
|
const parentChildCount = callArgs.types.filter((t: string) => t === 'parent_child').length;
|
||||||
|
expect(parentChildCount).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -244,8 +244,8 @@ describe('GraphService', () => {
|
||||||
|
|
||||||
expect(result.nodes).toHaveLength(0);
|
expect(result.nodes).toHaveLength(0);
|
||||||
expect(result.edges).toHaveLength(0);
|
expect(result.edges).toHaveLength(0);
|
||||||
// loadEdges returns early for empty types without calling SQL
|
// With an empty types list, no backlink types remain so loadEdges is skipped entirely.
|
||||||
expect(loadEdgesSpy).toHaveBeenCalled();
|
expect(loadEdgesSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -382,6 +382,7 @@ describe('GraphService', () => {
|
||||||
{
|
{
|
||||||
id: 'cached-p1',
|
id: 'cached-p1',
|
||||||
label: 'Cached',
|
label: 'Cached',
|
||||||
|
slug: null,
|
||||||
type: 'page',
|
type: 'page',
|
||||||
spaceId: 'sp-1',
|
spaceId: 'sp-1',
|
||||||
spaceName: 'S',
|
spaceName: 'S',
|
||||||
|
|
@ -497,4 +498,129 @@ describe('GraphService', () => {
|
||||||
expect(result.nodes).toHaveLength(0);
|
expect(result.nodes).toHaveLength(0);
|
||||||
expect(result.edges).toHaveLength(0);
|
expect(result.edges).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// R4.6 — parent_child edges
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('parent_child edges appear when types includes parent_child', async () => {
|
||||||
|
// Backlink table is empty — only parent-child edges exist
|
||||||
|
jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]);
|
||||||
|
jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([
|
||||||
|
{ source_page_id: 'parent-1', target_page_id: 'child-1', link_type: 'parent_child', weight: 1 },
|
||||||
|
{ source_page_id: 'parent-1', target_page_id: 'child-2', link_type: 'parent_child', weight: 1 },
|
||||||
|
]);
|
||||||
|
jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
|
||||||
|
pageMeta('parent-1', 'Parent'),
|
||||||
|
pageMeta('child-1', 'Child A'),
|
||||||
|
pageMeta('child-2', 'Child B'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.buildGraph({
|
||||||
|
...baseOpts(),
|
||||||
|
types: ['parent_child'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(result.nodes).toHaveLength(3);
|
||||||
|
expect(result.edges).toHaveLength(2);
|
||||||
|
expect(result.edges.every((e) => e.type === 'parent_child')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parent_child edges carry correct source -> target direction', async () => {
|
||||||
|
jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]);
|
||||||
|
jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([
|
||||||
|
{ source_page_id: 'parent-1', target_page_id: 'child-1', link_type: 'parent_child', weight: 1 },
|
||||||
|
]);
|
||||||
|
jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
|
||||||
|
pageMeta('parent-1', 'Parent'),
|
||||||
|
pageMeta('child-1', 'Child'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.buildGraph({
|
||||||
|
...baseOpts(),
|
||||||
|
types: ['parent_child'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const edge = result.edges[0];
|
||||||
|
expect(edge.source).toBe('parent-1');
|
||||||
|
expect(edge.target).toBe('child-1');
|
||||||
|
expect(edge.id).toBe('parent-1:child-1:parent_child');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters parent_child edges by spaceId', async () => {
|
||||||
|
const loadParentSpy = jest
|
||||||
|
.spyOn(service as any, 'loadParentChildEdges')
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]);
|
||||||
|
jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.buildGraph({
|
||||||
|
...baseOpts(),
|
||||||
|
spaceId: 'space-99',
|
||||||
|
types: ['parent_child'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loadParentSpy).toHaveBeenCalledWith('ws-1', 'user-1', 'space-99');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadParentChildEdges is NOT called when parent_child is excluded from types', async () => {
|
||||||
|
const loadParentSpy = jest
|
||||||
|
.spyOn(service as any, 'loadParentChildEdges')
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]);
|
||||||
|
jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.buildGraph({
|
||||||
|
...baseOpts(),
|
||||||
|
types: ['wikilink', 'mention'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loadParentSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed wikilink + parent_child edges are merged and all returned', async () => {
|
||||||
|
jest.spyOn(service as any, 'loadEdges').mockResolvedValue([
|
||||||
|
row('p1', 'p2', 'wikilink'),
|
||||||
|
]);
|
||||||
|
jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([
|
||||||
|
{ source_page_id: 'p2', target_page_id: 'p3', link_type: 'parent_child', weight: 1 },
|
||||||
|
]);
|
||||||
|
jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
|
||||||
|
pageMeta('p1'),
|
||||||
|
pageMeta('p2'),
|
||||||
|
pageMeta('p3'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.buildGraph({
|
||||||
|
...baseOpts(),
|
||||||
|
types: ['wikilink', 'parent_child'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.edges).toHaveLength(2);
|
||||||
|
const types = result.edges.map((e) => e.type).sort();
|
||||||
|
expect(types).toEqual(['parent_child', 'wikilink']);
|
||||||
|
expect(result.nodes).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nodes include slug field from page metadata', async () => {
|
||||||
|
jest.spyOn(service as any, 'loadEdges').mockResolvedValue([row('p1', 'p2')]);
|
||||||
|
jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([]);
|
||||||
|
jest.spyOn(service as any, 'loadPageMeta').mockImplementation(
|
||||||
|
async (..._args: any[]) => [
|
||||||
|
{ id: 'p1', title: 'Page 1', slug: 'page-1-slug', space_id: 'sp-1', space_name: 'S', icon: null },
|
||||||
|
{ id: 'p2', title: 'Page 2', slug: null, space_id: 'sp-1', space_name: 'S', icon: null },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.buildGraph({
|
||||||
|
...baseOpts(),
|
||||||
|
types: ['wikilink'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const p1 = result.nodes.find((n) => n.id === 'p1')!;
|
||||||
|
const p2 = result.nodes.find((n) => n.id === 'p2')!;
|
||||||
|
expect(p1.slug).toBe('page-1-slug');
|
||||||
|
expect(p2.slug).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue