diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index aae2a5d8..8120a70c 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1139,6 +1139,12 @@ "graph.untitled_page": "(untitled)", "graph.error_title": "Graph load error", "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.create_button": "New template", "templates.create_title": "Create template", diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 3331d5f8..2818b806 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -1094,6 +1094,12 @@ "graph.untitled_page": "(sans titre)", "graph.error_title": "Erreur de chargement du graphe", "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.create_button": "Nouveau modele", "templates.create_title": "Creer un modele", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 2c7da43a..99d579df 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -60,6 +60,12 @@ import AcadeniceNotificationsPage from "@/features/acadenice/notifications/pages import NotificationPreferencesPage from "@/features/acadenice/notifications/pages/notification-preferences-page"; // Acadenice R4.3 — Web Clipper token management 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() { const { t } = useTranslation(); @@ -118,6 +124,8 @@ export default function App() { /> } /> } /> + {/* Acadenice R4.6 — space-scoped graph view */} + } /> } @@ -159,6 +167,10 @@ export default function App() { } /> {/* Acadenice R4.4 — Workspace branding */} } /> + {/* Acadenice R4.5 — open source EE replacements */} + } /> + } /> + } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/features/acadenice/graph/__tests__/graph-canvas.smoke.test.tsx b/apps/client/src/features/acadenice/graph/__tests__/graph-canvas.smoke.test.tsx index 35b0f95b..bc95cc90 100644 --- a/apps/client/src/features/acadenice/graph/__tests__/graph-canvas.smoke.test.tsx +++ b/apps/client/src/features/acadenice/graph/__tests__/graph-canvas.smoke.test.tsx @@ -7,7 +7,7 @@ */ 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 { MemoryRouter } from "react-router-dom"; import { MantineProvider } from "@mantine/core"; @@ -25,6 +25,7 @@ const NODES: GraphNode[] = [ { id: "p1", label: "Page 1", + slug: "page-1", type: "page", spaceId: "s1", spaceName: "Engineering", @@ -35,6 +36,7 @@ const NODES: GraphNode[] = [ { id: "p2", label: "Page 2", + slug: null, type: "page", spaceId: "s1", 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) { const store = createStore(); return render( @@ -107,6 +119,7 @@ describe("GraphCanvas smoke", () => { const orphan: GraphNode = { id: "orphan-1", label: "Orphan", + slug: "orphan-1", type: "page", spaceId: "s2", spaceName: null, @@ -121,6 +134,7 @@ describe("GraphCanvas smoke", () => { const noLabel: GraphNode = { id: "p3", label: null, + slug: null, type: "page", spaceId: "s1", spaceName: "Test", @@ -130,4 +144,41 @@ describe("GraphCanvas smoke", () => { }; 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(); + }); }); diff --git a/apps/client/src/features/acadenice/graph/__tests__/graph-controls.test.tsx b/apps/client/src/features/acadenice/graph/__tests__/graph-controls.test.tsx index 59748ade..91d88ed2 100644 --- a/apps/client/src/features/acadenice/graph/__tests__/graph-controls.test.tsx +++ b/apps/client/src/features/acadenice/graph/__tests__/graph-controls.test.tsx @@ -42,6 +42,7 @@ const MOCK_NODES = [ { id: "p1", label: "Page 1", + slug: "page-1", type: "page" as const, spaceId: "s1", spaceName: "Engineering", diff --git a/apps/client/src/features/acadenice/graph/__tests__/graph-side-panel.test.tsx b/apps/client/src/features/acadenice/graph/__tests__/graph-side-panel.test.tsx index 77a70a5d..1667a3bb 100644 --- a/apps/client/src/features/acadenice/graph/__tests__/graph-side-panel.test.tsx +++ b/apps/client/src/features/acadenice/graph/__tests__/graph-side-panel.test.tsx @@ -19,6 +19,7 @@ vi.mock("react-i18next", () => ({ const MOCK_NODE: GraphNode = { id: "p1", label: "My Page", + slug: "my-page", type: "page", spaceId: "s1", spaceName: "Engineering", @@ -94,20 +95,35 @@ describe("GraphSidePanel", () => { expect(screen.queryByText(/orphan/i)).toBeNull(); }); - it("open-page button is disabled when slugMap has no entry", () => { - renderPanel(MOCK_NODE, {}, true); + it("open-page button is disabled when both node.slug and slugMap have no entry", () => { + // 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"); expect(btn?.hasAttribute("disabled") || btn?.getAttribute("data-disabled")).toBeTruthy(); }); - it("open-page button is enabled when slugMap has entry", () => { - renderPanel(MOCK_NODE, { p1: "my-page-slug" }, true); + it("open-page button is enabled when node.slug is set", () => { + // MOCK_NODE has slug: "my-page" + renderPanel(MOCK_NODE, {}, true); const btn = screen.getByText("graph.open_page").closest("button"); expect(btn?.hasAttribute("disabled")).toBeFalsy(); }); - it("renders untitled label when node.label is null", () => { - renderPanel({ ...MOCK_NODE, label: null }, {}, true); + it("open-page button is enabled when slugMap has entry even if node.slug is null", () => { + 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(); }); }); diff --git a/apps/client/src/features/acadenice/graph/components/graph-canvas.tsx b/apps/client/src/features/acadenice/graph/components/graph-canvas.tsx index a697e560..f1a0afc9 100644 --- a/apps/client/src/features/acadenice/graph/components/graph-canvas.tsx +++ b/apps/client/src/features/acadenice/graph/components/graph-canvas.tsx @@ -23,7 +23,8 @@ import React, { useRef, Suspense, } 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 { GraphNode, GraphEdge } from "../services/graph-client"; import { @@ -39,6 +40,7 @@ import { interface ForceGraphNode { id: string; label: string | null; + slug: string | null; spaceId: string; spaceName: string | null; isOrphan: boolean; @@ -52,7 +54,7 @@ interface ForceGraphNode { interface ForceGraphLink { source: string | ForceGraphNode; target: string | ForceGraphNode; - edgeType: "wikilink" | "mention" | "database_embed"; + edgeType: "wikilink" | "mention" | "database_embed" | "parent_child"; weight: number; id: string; } @@ -71,6 +73,7 @@ const EDGE_COLORS: Record = { wikilink: "#4dabf7", mention: "#51cf66", database_embed: "#ff922b", + parent_child: "#868e96", }; /** Log-scale node radius proportional to in+out degree (min 4, max 18). */ @@ -185,6 +188,7 @@ export function GraphCanvas({ height, slugMap = {}, }: GraphCanvasProps) { + const { t } = useTranslation(); const theme = useMantineTheme(); const { colorScheme } = useMantineColorScheme(); const navigate = useNavigate(); @@ -200,6 +204,7 @@ export function GraphCanvas({ nodes.map((n) => ({ id: n.id, label: n.label, + slug: n.slug, spaceId: n.spaceId, spaceName: n.spaceName, isOrphan: n.isOrphan, @@ -311,9 +316,9 @@ export function GraphCanvas({ : dimmed ? "#444" : spaceColor(node.spaceId); ctx.fill(); - // Label visible at sufficient zoom + // Label visible at sufficient zoom — fallback chain: title > slug > untitled 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); ctx.font = `${fontSize}px sans-serif`; ctx.textAlign = "center"; @@ -326,7 +331,7 @@ export function GraphCanvas({ ctx.fillText(label, x, y + r + 2 / globalScale); } }, - [selectedNodeId, neighborIds, matchIds, colorScheme], + [selectedNodeId, neighborIds, matchIds, colorScheme, t], ); /* ---- Link color ---- */ @@ -334,9 +339,9 @@ export function GraphCanvas({ const linkColor = useCallback( (link: ForceGraphLink) => { 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( - selectedNodeId && s !== selectedNodeId && t !== selectedNodeId, + selectedNodeId && s !== selectedNodeId && tgt !== selectedNodeId, ); const base = EDGE_COLORS[link.edgeType] ?? "#adb5bd"; return dimmed ? "#3a3a3a" : base; @@ -344,6 +349,15 @@ export function GraphCanvas({ [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 ---- */ const handleNodeClick = useCallback( @@ -357,7 +371,9 @@ export function GraphCanvas({ const handleNodeDoubleClick = useCallback( (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}`); }, [slugMap, navigate], @@ -383,31 +399,76 @@ export function GraphCanvas({ const ForceGraph2D = ForceGraph2DLazy; return ( - }> - { - graphRef.current = ref; +
+ }> + { + graphRef.current = ref; + }} + graphData={{ nodes: graphNodes, links: graphLinks }} + width={width} + height={height} + backgroundColor={bgColor} + nodeCanvasObject={paintNode} + nodeCanvasObjectMode={() => "replace"} + linkColor={linkColor} + linkLineDash={linkLineDash} + linkWidth={(link: ForceGraphLink) => + Math.min(0.5 + (link.weight - 1) * 0.3, 3) + } + linkDirectionalArrowLength={4} + linkDirectionalArrowRelPos={1} + linkOpacity={0.6} + onNodeClick={handleNodeClick} + onNodeRightClick={handleNodeRightClick} + onNodeDoubleClick={handleNodeDoubleClick} + nodeLabel={(node: ForceGraphNode) => + node.label ?? node.slug ?? t("graph.untitled_page") + } + enableNodeDrag + cooldownTicks={200} + /> + + + {/* Edge type legend — accessible via aria-label */} + "replace"} - linkColor={linkColor} - linkWidth={(link: ForceGraphLink) => - Math.min(0.5 + (link.weight - 1) * 0.3, 3) - } - linkDirectionalArrowLength={4} - linkDirectionalArrowRelPos={1} - linkOpacity={0.6} - onNodeClick={handleNodeClick} - onNodeRightClick={handleNodeRightClick} - onNodeDoubleClick={handleNodeDoubleClick} - nodeLabel={(node: ForceGraphNode) => node.label ?? "(untitled)"} - enableNodeDrag - cooldownTicks={200} - /> - + > + + {t("graph.legend_title")} + + + + + {t("graph.legend_wikilink")} + + + + {t("graph.legend_parent_child")} + + + +
); } diff --git a/apps/client/src/features/acadenice/graph/components/graph-side-panel.tsx b/apps/client/src/features/acadenice/graph/components/graph-side-panel.tsx index 82031981..7e538715 100644 --- a/apps/client/src/features/acadenice/graph/components/graph-side-panel.tsx +++ b/apps/client/src/features/acadenice/graph/components/graph-side-panel.tsx @@ -34,7 +34,8 @@ export function GraphSidePanel({ node, slugMap }: GraphSidePanelProps) { function openPage() { 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}`); close(); } @@ -57,7 +58,7 @@ export function GraphSidePanel({ node, slugMap }: GraphSidePanelProps) { - {node.label ?? t("graph.untitled_page")} + {node.label ?? node.slug ?? t("graph.untitled_page")}