@grackle-ai/web-components 0.107.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.rush/temp/3ae72563f781afd72723475938136f113846603e.untar.log +10 -0
- package/.rush/temp/bc1d5bf9201ce71abeaeaddd096deb9b0805d703.untar.log +10 -0
- package/.rush/temp/operation/_phase_build/all.log +18 -0
- package/.rush/temp/operation/_phase_build/log-chunks.jsonl +18 -0
- package/.rush/temp/operation/_phase_build/state.json +3 -0
- package/.rush/temp/operation/_phase_test/all.log +121 -0
- package/.rush/temp/operation/_phase_test/log-chunks.jsonl +121 -0
- package/.rush/temp/operation/_phase_test/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +938 -0
- package/.storybook/main.ts +22 -0
- package/.storybook/preview.tsx +30 -0
- package/config/rig.json +4 -0
- package/config/rush-project.json +12 -0
- package/dist/index.css +1 -0
- package/dist/index.js +39221 -0
- package/eslint.config.cjs +5 -0
- package/package.json +83 -0
- package/rush-logs/web-components._phase_build.cache.log +4 -0
- package/rush-logs/web-components._phase_test.cache.log +4 -0
- package/src/components/chat/ChatInput.module.scss +81 -0
- package/src/components/chat/ChatInput.stories.tsx +91 -0
- package/src/components/chat/ChatInput.tsx +168 -0
- package/src/components/chat/index.ts +6 -0
- package/src/components/dag/DagView.module.scss +149 -0
- package/src/components/dag/DagView.stories.tsx +125 -0
- package/src/components/dag/DagView.tsx +109 -0
- package/src/components/dag/TaskNode.stories.tsx +133 -0
- package/src/components/dag/TaskNode.tsx +40 -0
- package/src/components/dag/useDagLayout.ts +139 -0
- package/src/components/display/Breadcrumbs.module.scss +71 -0
- package/src/components/display/Breadcrumbs.stories.tsx +80 -0
- package/src/components/display/Breadcrumbs.tsx +46 -0
- package/src/components/display/Button.module.scss +110 -0
- package/src/components/display/Button.stories.tsx +88 -0
- package/src/components/display/Button.tsx +40 -0
- package/src/components/display/ConfirmDialog.module.scss +67 -0
- package/src/components/display/ConfirmDialog.stories.tsx +81 -0
- package/src/components/display/ConfirmDialog.tsx +88 -0
- package/src/components/display/CopyButton.module.scss +41 -0
- package/src/components/display/CopyButton.stories.tsx +78 -0
- package/src/components/display/CopyButton.tsx +64 -0
- package/src/components/display/DemoBanner.module.scss +37 -0
- package/src/components/display/DemoBanner.stories.tsx +40 -0
- package/src/components/display/DemoBanner.tsx +23 -0
- package/src/components/display/EventHoverRow.module.scss +102 -0
- package/src/components/display/EventHoverRow.stories.tsx +99 -0
- package/src/components/display/EventHoverRow.tsx +154 -0
- package/src/components/display/EventRenderer.module.scss +272 -0
- package/src/components/display/EventRenderer.stories.tsx +186 -0
- package/src/components/display/EventRenderer.tsx +271 -0
- package/src/components/display/EventStream.module.scss +93 -0
- package/src/components/display/EventStream.stories.tsx +249 -0
- package/src/components/display/EventStream.tsx +369 -0
- package/src/components/display/FloatingActionBar.module.scss +107 -0
- package/src/components/display/FloatingActionBar.stories.tsx +122 -0
- package/src/components/display/FloatingActionBar.tsx +119 -0
- package/src/components/display/SessionAttemptSelector.module.scss +50 -0
- package/src/components/display/SessionAttemptSelector.stories.tsx +78 -0
- package/src/components/display/SessionAttemptSelector.tsx +49 -0
- package/src/components/display/SessionPicker.module.scss +200 -0
- package/src/components/display/SessionPicker.stories.tsx +169 -0
- package/src/components/display/SessionPicker.tsx +214 -0
- package/src/components/display/Skeleton.module.scss +58 -0
- package/src/components/display/Skeleton.stories.tsx +94 -0
- package/src/components/display/Skeleton.tsx +127 -0
- package/src/components/display/Spinner.module.scss +41 -0
- package/src/components/display/Spinner.stories.tsx +66 -0
- package/src/components/display/Spinner.tsx +32 -0
- package/src/components/display/SplashScreen.module.scss +20 -0
- package/src/components/display/SplashScreen.stories.tsx +26 -0
- package/src/components/display/SplashScreen.tsx +16 -0
- package/src/components/display/SplitButton.module.scss +166 -0
- package/src/components/display/SplitButton.stories.tsx +95 -0
- package/src/components/display/SplitButton.tsx +128 -0
- package/src/components/display/Tooltip.module.scss +84 -0
- package/src/components/display/Tooltip.stories.tsx +240 -0
- package/src/components/display/Tooltip.tsx +184 -0
- package/src/components/display/extractText.test.tsx +48 -0
- package/src/components/display/index.ts +20 -0
- package/src/components/editable/EditableCheckbox.stories.tsx +54 -0
- package/src/components/editable/EditableCheckbox.tsx +39 -0
- package/src/components/editable/EditableField.module.scss +135 -0
- package/src/components/editable/EditableSelect.tsx +164 -0
- package/src/components/editable/EditableTextArea.stories.tsx +50 -0
- package/src/components/editable/EditableTextArea.tsx +148 -0
- package/src/components/editable/EditableTextField.stories.tsx +62 -0
- package/src/components/editable/EditableTextField.tsx +153 -0
- package/src/components/editable/EnvironmentSelect.module.scss +17 -0
- package/src/components/editable/EnvironmentSelect.stories.tsx +61 -0
- package/src/components/editable/EnvironmentSelect.tsx +87 -0
- package/src/components/editable/index.ts +13 -0
- package/src/components/editable/useEditableField.test.tsx +233 -0
- package/src/components/editable/useEditableField.ts +173 -0
- package/src/components/index.ts +20 -0
- package/src/components/knowledge/KnowledgeDetailPanel.module.scss +162 -0
- package/src/components/knowledge/KnowledgeDetailPanel.stories.tsx +208 -0
- package/src/components/knowledge/KnowledgeDetailPanel.tsx +122 -0
- package/src/components/knowledge/KnowledgeGraph.module.scss +110 -0
- package/src/components/knowledge/KnowledgeGraph.stories.tsx +180 -0
- package/src/components/knowledge/KnowledgeGraph.tsx +455 -0
- package/src/components/knowledge/KnowledgeNav.module.scss +130 -0
- package/src/components/knowledge/KnowledgeNav.stories.tsx +108 -0
- package/src/components/knowledge/KnowledgeNav.tsx +138 -0
- package/src/components/knowledge/index.ts +3 -0
- package/src/components/layout/AppNav.module.scss +82 -0
- package/src/components/layout/AppNav.stories.tsx +115 -0
- package/src/components/layout/AppNav.tsx +133 -0
- package/src/components/layout/BottomStatusBar.module.scss +58 -0
- package/src/components/layout/BottomStatusBar.stories.tsx +35 -0
- package/src/components/layout/BottomStatusBar.tsx +206 -0
- package/src/components/layout/Sidebar.module.scss +60 -0
- package/src/components/layout/Sidebar.stories.tsx +46 -0
- package/src/components/layout/Sidebar.tsx +84 -0
- package/src/components/layout/StatusBar.module.scss +108 -0
- package/src/components/layout/StatusBar.stories.tsx +119 -0
- package/src/components/layout/StatusBar.tsx +70 -0
- package/src/components/layout/index.ts +9 -0
- package/src/components/lists/EnvironmentNav.module.scss +118 -0
- package/src/components/lists/EnvironmentNav.stories.tsx +121 -0
- package/src/components/lists/EnvironmentNav.tsx +133 -0
- package/src/components/lists/FindingsNav.module.scss +126 -0
- package/src/components/lists/FindingsNav.tsx +146 -0
- package/src/components/lists/TaskList.module.scss +206 -0
- package/src/components/lists/TaskList.stories.tsx +401 -0
- package/src/components/lists/TaskList.tsx +509 -0
- package/src/components/lists/index.ts +6 -0
- package/src/components/lists/listHelpers.tsx +130 -0
- package/src/components/notifications/Callout.module.scss +83 -0
- package/src/components/notifications/Callout.stories.tsx +81 -0
- package/src/components/notifications/Callout.tsx +64 -0
- package/src/components/notifications/Toast.module.scss +86 -0
- package/src/components/notifications/Toast.stories.tsx +71 -0
- package/src/components/notifications/Toast.tsx +51 -0
- package/src/components/notifications/ToastContainer.module.scss +23 -0
- package/src/components/notifications/ToastContainer.stories.tsx +66 -0
- package/src/components/notifications/ToastContainer.tsx +29 -0
- package/src/components/notifications/UpdateBanner.stories.tsx +77 -0
- package/src/components/notifications/UpdateBanner.test.tsx +64 -0
- package/src/components/notifications/UpdateBanner.tsx +44 -0
- package/src/components/notifications/index.ts +8 -0
- package/src/components/panels/AboutPanel.stories.tsx +70 -0
- package/src/components/panels/AboutPanel.tsx +66 -0
- package/src/components/panels/AppearancePanel.stories.tsx +45 -0
- package/src/components/panels/AppearancePanel.tsx +97 -0
- package/src/components/panels/CredentialProvidersPanel.stories.tsx +62 -0
- package/src/components/panels/CredentialProvidersPanel.tsx +111 -0
- package/src/components/panels/EnvironmentEditPanel.module.scss +170 -0
- package/src/components/panels/EnvironmentEditPanel.stories.tsx +206 -0
- package/src/components/panels/EnvironmentEditPanel.tsx +785 -0
- package/src/components/panels/FindingsPanel.module.scss +94 -0
- package/src/components/panels/FindingsPanel.stories.tsx +109 -0
- package/src/components/panels/FindingsPanel.tsx +76 -0
- package/src/components/panels/KeyboardShortcutsPanel.module.scss +65 -0
- package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +40 -0
- package/src/components/panels/KeyboardShortcutsPanel.tsx +104 -0
- package/src/components/panels/PluginsPanel.tsx +77 -0
- package/src/components/panels/SettingsPanel.module.scss +336 -0
- package/src/components/panels/TaskActionButtons.module.scss +22 -0
- package/src/components/panels/TaskActionButtons.stories.tsx +125 -0
- package/src/components/panels/TaskActionButtons.tsx +87 -0
- package/src/components/panels/TaskEditPanel.module.scss +202 -0
- package/src/components/panels/TaskEditPanel.stories.tsx +75 -0
- package/src/components/panels/TaskEditPanel.tsx +328 -0
- package/src/components/panels/TaskOverviewPanel.module.scss +236 -0
- package/src/components/panels/TaskOverviewPanel.stories.tsx +219 -0
- package/src/components/panels/TaskOverviewPanel.tsx +270 -0
- package/src/components/panels/TokensPanel.stories.tsx +131 -0
- package/src/components/panels/TokensPanel.tsx +143 -0
- package/src/components/panels/WorkpadPanel.module.scss +39 -0
- package/src/components/panels/WorkpadPanel.stories.tsx +56 -0
- package/src/components/panels/WorkpadPanel.tsx +63 -0
- package/src/components/panels/index.ts +13 -0
- package/src/components/personas/McpToolSelector.module.scss +109 -0
- package/src/components/personas/McpToolSelector.stories.tsx +129 -0
- package/src/components/personas/McpToolSelector.tsx +180 -0
- package/src/components/personas/PersonaManager.module.scss +233 -0
- package/src/components/personas/PersonaManager.stories.tsx +139 -0
- package/src/components/personas/PersonaManager.tsx +122 -0
- package/src/components/schedules/ScheduleManager.module.scss +98 -0
- package/src/components/schedules/ScheduleManager.stories.tsx +78 -0
- package/src/components/schedules/ScheduleManager.tsx +160 -0
- package/src/components/settings/SettingsNav.module.scss +82 -0
- package/src/components/settings/SettingsNav.stories.tsx +83 -0
- package/src/components/settings/SettingsNav.tsx +104 -0
- package/src/components/streams/StreamDetailPanel.module.scss +206 -0
- package/src/components/streams/StreamDetailPanel.stories.tsx +132 -0
- package/src/components/streams/StreamDetailPanel.tsx +119 -0
- package/src/components/streams/StreamList.module.scss +92 -0
- package/src/components/streams/StreamList.stories.tsx +99 -0
- package/src/components/streams/StreamList.tsx +114 -0
- package/src/components/streams/index.ts +10 -0
- package/src/components/tools/AgentToolCard.module.scss +118 -0
- package/src/components/tools/AgentToolCard.stories.tsx +304 -0
- package/src/components/tools/AgentToolCard.tsx +247 -0
- package/src/components/tools/FileEditCard.stories.tsx +138 -0
- package/src/components/tools/FileEditCard.tsx +160 -0
- package/src/components/tools/FileReadCard.stories.tsx +120 -0
- package/src/components/tools/FileReadCard.tsx +106 -0
- package/src/components/tools/FindingCard.stories.tsx +124 -0
- package/src/components/tools/FindingCard.tsx +178 -0
- package/src/components/tools/GenericToolCard.stories.tsx +80 -0
- package/src/components/tools/GenericToolCard.tsx +111 -0
- package/src/components/tools/IpcCard.stories.tsx +129 -0
- package/src/components/tools/IpcCard.tsx +178 -0
- package/src/components/tools/KnowledgeCard.stories.tsx +112 -0
- package/src/components/tools/KnowledgeCard.tsx +165 -0
- package/src/components/tools/MetadataCard.stories.tsx +32 -0
- package/src/components/tools/MetadataCard.tsx +39 -0
- package/src/components/tools/SearchCard.stories.tsx +74 -0
- package/src/components/tools/SearchCard.tsx +86 -0
- package/src/components/tools/ShellCard.stories.tsx +112 -0
- package/src/components/tools/ShellCard.tsx +106 -0
- package/src/components/tools/TaskCard.stories.tsx +123 -0
- package/src/components/tools/TaskCard.tsx +203 -0
- package/src/components/tools/TodoCard.module.scss +131 -0
- package/src/components/tools/TodoCard.stories.tsx +202 -0
- package/src/components/tools/TodoCard.tsx +200 -0
- package/src/components/tools/ToolCard.stories.tsx +177 -0
- package/src/components/tools/ToolCard.tsx +60 -0
- package/src/components/tools/ToolCardProps.ts +20 -0
- package/src/components/tools/ToolSearchCard.stories.tsx +81 -0
- package/src/components/tools/ToolSearchCard.tsx +86 -0
- package/src/components/tools/WorkpadCard.stories.tsx +106 -0
- package/src/components/tools/WorkpadCard.tsx +125 -0
- package/src/components/tools/classifyTool.test.ts +44 -0
- package/src/components/tools/classifyTool.ts +134 -0
- package/src/components/tools/parseDiff.ts +95 -0
- package/src/components/tools/parseShellOutput.ts +28 -0
- package/src/components/tools/toolCardHelpers.test.ts +53 -0
- package/src/components/tools/toolCards.module.scss +234 -0
- package/src/components/workspace/WorkspaceBoard.module.scss +238 -0
- package/src/components/workspace/WorkspaceBoard.stories.tsx +240 -0
- package/src/components/workspace/WorkspaceBoard.tsx +232 -0
- package/src/components/workspace/WorkspaceFormFields.module.scss +79 -0
- package/src/components/workspace/WorkspaceFormFields.stories.tsx +133 -0
- package/src/components/workspace/WorkspaceFormFields.tsx +185 -0
- package/src/context/GrackleContext.ts +28 -0
- package/src/context/GrackleContextTypes.ts +64 -0
- package/src/context/SidebarContext.tsx +53 -0
- package/src/context/ThemeContext.tsx +21 -0
- package/src/context/ToastContext.tsx +56 -0
- package/src/hooks/types.ts +864 -0
- package/src/hooks/useEventSelection.test.ts +204 -0
- package/src/hooks/useEventSelection.ts +158 -0
- package/src/hooks/useSmartScroll.ts +151 -0
- package/src/hooks/useTheme.ts +228 -0
- package/src/index.ts +210 -0
- package/src/mocks/MockGrackleProvider.tsx +1397 -0
- package/src/mocks/mockData.ts +1966 -0
- package/src/mocks/mockKnowledgeData.ts +294 -0
- package/src/scss.d.ts +12 -0
- package/src/styles/global.scss +244 -0
- package/src/styles/mixins.scss +278 -0
- package/src/styles/prism-theme.scss +148 -0
- package/src/styles/theme.scss +1102 -0
- package/src/test-utils/storybook-decorators.tsx +50 -0
- package/src/test-utils/storybook-helpers.ts +262 -0
- package/src/themes.ts +142 -0
- package/src/utils/boardColumns.ts +141 -0
- package/src/utils/breadcrumbs.test.ts +285 -0
- package/src/utils/breadcrumbs.ts +222 -0
- package/src/utils/dashboard.test.ts +156 -0
- package/src/utils/dashboard.ts +195 -0
- package/src/utils/eventContent.test.ts +353 -0
- package/src/utils/eventContent.ts +209 -0
- package/src/utils/findingCategory.ts +33 -0
- package/src/utils/format.ts +27 -0
- package/src/utils/iconSize.ts +18 -0
- package/src/utils/navigation.ts +205 -0
- package/src/utils/route-config.test.ts +128 -0
- package/src/utils/scrollUtils.test.ts +65 -0
- package/src/utils/scrollUtils.ts +49 -0
- package/src/utils/sessionEvents.test.ts +302 -0
- package/src/utils/sessionEvents.ts +233 -0
- package/src/utils/taskStatus.tsx +137 -0
- package/src/utils/time.ts +92 -0
- package/tsconfig.json +8 -0
- package/vite.config.ts +20 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Force-directed knowledge graph visualization using d3-force + SVG.
|
|
3
|
+
*
|
|
4
|
+
* Renders nodes as styled SVG elements with CSS theming support,
|
|
5
|
+
* glassmorphic cards, glow effects, and smooth transitions.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useRef, useEffect, useState, type JSX } from "react";
|
|
11
|
+
import {
|
|
12
|
+
forceSimulation,
|
|
13
|
+
forceLink,
|
|
14
|
+
forceManyBody,
|
|
15
|
+
forceCenter,
|
|
16
|
+
forceCollide,
|
|
17
|
+
type Simulation,
|
|
18
|
+
type SimulationNodeDatum,
|
|
19
|
+
type SimulationLinkDatum,
|
|
20
|
+
} from "d3-force";
|
|
21
|
+
import { drag, type D3DragEvent } from "d3-drag";
|
|
22
|
+
import { select, type Selection } from "d3-selection";
|
|
23
|
+
import { zoom, zoomIdentity, type ZoomBehavior } from "d3-zoom";
|
|
24
|
+
import "d3-transition";
|
|
25
|
+
import type { GraphNode, GraphLink } from "../../hooks/types.js";
|
|
26
|
+
import styles from "./KnowledgeGraph.module.scss";
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Types
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
interface SimNode extends SimulationNodeDatum, GraphNode {}
|
|
33
|
+
|
|
34
|
+
interface SimLink extends SimulationLinkDatum<SimNode> {
|
|
35
|
+
type: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Constants
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const NODE_COLORS: Record<string, string> = {
|
|
43
|
+
reference: "#4A9EFF",
|
|
44
|
+
decision: "#22C55E",
|
|
45
|
+
insight: "#EAB308",
|
|
46
|
+
concept: "#A855F7",
|
|
47
|
+
snippet: "#6B7280",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function getNodeColor(node: GraphNode): string {
|
|
51
|
+
if (node.kind === "reference") {
|
|
52
|
+
return NODE_COLORS.reference;
|
|
53
|
+
}
|
|
54
|
+
return NODE_COLORS[node.category ?? "insight"] ?? NODE_COLORS.insight;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const NODE_WIDTH: number = 200;
|
|
58
|
+
const NODE_HEIGHT: number = 52;
|
|
59
|
+
const NODE_RADIUS: number = 12;
|
|
60
|
+
|
|
61
|
+
/** Padding around the bounding box when computing zoom-to-fit. */
|
|
62
|
+
const FIT_PADDING: number = 40;
|
|
63
|
+
|
|
64
|
+
/** Minimum drag distance (px) before a mouseup is treated as a drag-end rather than a click. */
|
|
65
|
+
const DRAG_CLICK_THRESHOLD: number = 3;
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
interface ZoomToFitResult {
|
|
72
|
+
translateX: number;
|
|
73
|
+
translateY: number;
|
|
74
|
+
scale: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compute the transform needed to fit all nodes within the viewport.
|
|
79
|
+
*
|
|
80
|
+
* Returns translate + scale that centers the node bounding box with padding.
|
|
81
|
+
* Scale is capped at 1.0 so small graphs are never zoomed in past 100%.
|
|
82
|
+
*/
|
|
83
|
+
function computeZoomToFit(
|
|
84
|
+
nodes: readonly { x?: number; y?: number }[],
|
|
85
|
+
viewport: { width: number; height: number },
|
|
86
|
+
): ZoomToFitResult | undefined {
|
|
87
|
+
if (viewport.width <= 0 || viewport.height <= 0) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Single-pass min/max to avoid stack overflow with large node counts
|
|
92
|
+
let minX: number = Infinity;
|
|
93
|
+
let maxX: number = -Infinity;
|
|
94
|
+
let minY: number = Infinity;
|
|
95
|
+
let maxY: number = -Infinity;
|
|
96
|
+
for (const n of nodes) {
|
|
97
|
+
const nx: number = n.x ?? 0;
|
|
98
|
+
const ny: number = n.y ?? 0;
|
|
99
|
+
if (nx < minX) { minX = nx; }
|
|
100
|
+
if (nx > maxX) { maxX = nx; }
|
|
101
|
+
if (ny < minY) { minY = ny; }
|
|
102
|
+
if (ny > maxY) { maxY = ny; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const x0: number = minX - NODE_WIDTH / 2 - FIT_PADDING;
|
|
106
|
+
const x1: number = maxX + NODE_WIDTH / 2 + FIT_PADDING;
|
|
107
|
+
const y0: number = minY - NODE_HEIGHT / 2 - FIT_PADDING;
|
|
108
|
+
const y1: number = maxY + NODE_HEIGHT / 2 + FIT_PADDING;
|
|
109
|
+
|
|
110
|
+
const bboxWidth: number = x1 - x0;
|
|
111
|
+
const bboxHeight: number = y1 - y0;
|
|
112
|
+
|
|
113
|
+
const scale: number = Math.min(
|
|
114
|
+
viewport.width / bboxWidth,
|
|
115
|
+
viewport.height / bboxHeight,
|
|
116
|
+
1.0,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const bboxCenterX: number = (x0 + x1) / 2;
|
|
120
|
+
const bboxCenterY: number = (y0 + y1) / 2;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
translateX: viewport.width / 2 - bboxCenterX * scale,
|
|
124
|
+
translateY: viewport.height / 2 - bboxCenterY * scale,
|
|
125
|
+
scale,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Component
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
interface KnowledgeGraphProps {
|
|
134
|
+
graphData: { nodes: GraphNode[]; links: GraphLink[] };
|
|
135
|
+
selectedNodeId?: string;
|
|
136
|
+
onNodeClick: (nodeId: string) => void;
|
|
137
|
+
onNodeDoubleClick: (nodeId: string) => void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function KnowledgeGraph({
|
|
141
|
+
graphData,
|
|
142
|
+
selectedNodeId,
|
|
143
|
+
onNodeClick,
|
|
144
|
+
onNodeDoubleClick,
|
|
145
|
+
}: KnowledgeGraphProps): JSX.Element {
|
|
146
|
+
const svgRef = useRef<SVGSVGElement>(null);
|
|
147
|
+
const gRef = useRef<SVGGElement>(null);
|
|
148
|
+
const simRef = useRef<Simulation<SimNode, SimLink> | undefined>(undefined);
|
|
149
|
+
const zoomRef = useRef<ZoomBehavior<SVGSVGElement, unknown> | undefined>(undefined);
|
|
150
|
+
const linkElsRef = useRef<Selection<SVGLineElement, SimLink, SVGGElement, unknown> | undefined>(undefined);
|
|
151
|
+
const nodeElsRef = useRef<Selection<SVGGElement, SimNode, SVGGElement, unknown> | undefined>(undefined);
|
|
152
|
+
const selectedNodeIdRef = useRef(selectedNodeId);
|
|
153
|
+
selectedNodeIdRef.current = selectedNodeId;
|
|
154
|
+
const didAutoFitRef = useRef(false);
|
|
155
|
+
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
|
|
156
|
+
const dragDistanceRef = useRef(0);
|
|
157
|
+
|
|
158
|
+
// Track container size
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const container: HTMLElement | null = svgRef.current?.parentElement ?? null;
|
|
161
|
+
if (!container) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const observer: ResizeObserver = new ResizeObserver((entries) => {
|
|
165
|
+
for (const entry of entries) {
|
|
166
|
+
setDimensions({ width: entry.contentRect.width, height: entry.contentRect.height });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
observer.observe(container);
|
|
170
|
+
setDimensions({ width: container.clientWidth, height: container.clientHeight });
|
|
171
|
+
return () => { observer.disconnect(); };
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
// Setup zoom
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (!svgRef.current || !gRef.current) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const svgEl: SVGSVGElement = svgRef.current;
|
|
180
|
+
const gEl: SVGGElement = gRef.current;
|
|
181
|
+
|
|
182
|
+
const zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> = zoom<SVGSVGElement, unknown>()
|
|
183
|
+
.scaleExtent([0.1, 4])
|
|
184
|
+
.filter((event: Event) => {
|
|
185
|
+
// Prevent zoom on double-click (we use it for expand)
|
|
186
|
+
if (event.type === "dblclick") {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
})
|
|
191
|
+
.on("zoom", (event) => {
|
|
192
|
+
select(gEl).attr("transform", String(event.transform));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
select(svgEl).call(zoomBehavior);
|
|
196
|
+
zoomRef.current = zoomBehavior;
|
|
197
|
+
return () => { select(svgEl).on(".zoom", null); };
|
|
198
|
+
}, []);
|
|
199
|
+
|
|
200
|
+
// Stable callback refs so d3 event handlers don't go stale
|
|
201
|
+
const onClickRef = useRef(onNodeClick);
|
|
202
|
+
onClickRef.current = onNodeClick;
|
|
203
|
+
const onDblClickRef = useRef(onNodeDoubleClick);
|
|
204
|
+
onDblClickRef.current = onNodeDoubleClick;
|
|
205
|
+
|
|
206
|
+
// Run simulation
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (!gRef.current) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const g: SVGGElement = gRef.current;
|
|
212
|
+
|
|
213
|
+
// Stop previous
|
|
214
|
+
if (simRef.current) {
|
|
215
|
+
simRef.current.stop();
|
|
216
|
+
simRef.current = undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Reset auto-fit flag when graph data changes so the next simulation run fits the view
|
|
220
|
+
didAutoFitRef.current = false;
|
|
221
|
+
|
|
222
|
+
if (graphData.nodes.length === 0) {
|
|
223
|
+
select(g).selectAll("*").remove();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Clone data for d3 mutation
|
|
228
|
+
const simNodes: SimNode[] = graphData.nodes.map((n) => ({ ...n }));
|
|
229
|
+
const nodeMap: Map<string, SimNode> = new Map(simNodes.map((n) => [n.id, n]));
|
|
230
|
+
const simLinks: SimLink[] = graphData.links
|
|
231
|
+
.filter((l) => nodeMap.has(l.source) && nodeMap.has(l.target))
|
|
232
|
+
.map((l) => ({ source: l.source, target: l.target, type: l.type }));
|
|
233
|
+
|
|
234
|
+
// Clear previous elements
|
|
235
|
+
select(g).selectAll("*").remove();
|
|
236
|
+
|
|
237
|
+
// Create link elements
|
|
238
|
+
const linkEls: Selection<SVGLineElement, SimLink, SVGGElement, unknown> = select(g)
|
|
239
|
+
.selectAll<SVGLineElement, SimLink>("line")
|
|
240
|
+
.data(simLinks)
|
|
241
|
+
.enter()
|
|
242
|
+
.append("line")
|
|
243
|
+
.attr("class", styles.link);
|
|
244
|
+
|
|
245
|
+
// Edge type tooltip on hover
|
|
246
|
+
linkEls.append("title")
|
|
247
|
+
.text((d: SimLink) => d.type);
|
|
248
|
+
|
|
249
|
+
linkElsRef.current = linkEls;
|
|
250
|
+
|
|
251
|
+
// Create node groups
|
|
252
|
+
const nodeEls: Selection<SVGGElement, SimNode, SVGGElement, unknown> = select(g)
|
|
253
|
+
.selectAll<SVGGElement, SimNode>("g.kg-node")
|
|
254
|
+
.data(simNodes)
|
|
255
|
+
.enter()
|
|
256
|
+
.append("g")
|
|
257
|
+
.attr("class", `kg-node ${styles.node}`)
|
|
258
|
+
.on("click", (_event: MouseEvent, d: SimNode) => {
|
|
259
|
+
// Suppress click if the user just finished dragging
|
|
260
|
+
if (dragDistanceRef.current > DRAG_CLICK_THRESHOLD) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
onClickRef.current(d.id);
|
|
264
|
+
})
|
|
265
|
+
.on("dblclick", (_event: MouseEvent, d: SimNode) => {
|
|
266
|
+
onDblClickRef.current(d.id);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
nodeElsRef.current = nodeEls;
|
|
270
|
+
|
|
271
|
+
// Node card background
|
|
272
|
+
nodeEls.append("rect")
|
|
273
|
+
.attr("class", styles.nodeCard)
|
|
274
|
+
.attr("width", NODE_WIDTH)
|
|
275
|
+
.attr("height", NODE_HEIGHT)
|
|
276
|
+
.attr("rx", NODE_RADIUS)
|
|
277
|
+
.attr("ry", NODE_RADIUS)
|
|
278
|
+
.style("--node-color", (d: SimNode) => getNodeColor(d));
|
|
279
|
+
|
|
280
|
+
// Category indicator bar
|
|
281
|
+
nodeEls.append("rect")
|
|
282
|
+
.attr("class", styles.nodeIndicator)
|
|
283
|
+
.attr("width", 4)
|
|
284
|
+
.attr("height", NODE_HEIGHT)
|
|
285
|
+
.attr("rx", 2)
|
|
286
|
+
.attr("fill", (d: SimNode) => getNodeColor(d));
|
|
287
|
+
|
|
288
|
+
// Node label
|
|
289
|
+
nodeEls.append("text")
|
|
290
|
+
.attr("class", styles.nodeLabel)
|
|
291
|
+
.attr("x", NODE_WIDTH / 2)
|
|
292
|
+
.attr("y", NODE_HEIGHT / 2 - 4)
|
|
293
|
+
.attr("text-anchor", "middle")
|
|
294
|
+
.attr("dominant-baseline", "central")
|
|
295
|
+
.text((d: SimNode) => d.label.length > 26 ? d.label.substring(0, 24) + "..." : d.label);
|
|
296
|
+
|
|
297
|
+
// Category badge
|
|
298
|
+
nodeEls.append("text")
|
|
299
|
+
.attr("class", styles.nodeBadge)
|
|
300
|
+
.attr("x", NODE_WIDTH / 2)
|
|
301
|
+
.attr("y", NODE_HEIGHT - 8)
|
|
302
|
+
.attr("text-anchor", "middle")
|
|
303
|
+
.text((d: SimNode) => (d.kind === "reference" ? d.sourceType ?? "ref" : d.category ?? "").toUpperCase());
|
|
304
|
+
|
|
305
|
+
// Simulation
|
|
306
|
+
const sim: Simulation<SimNode, SimLink> = forceSimulation(simNodes)
|
|
307
|
+
.force("link", forceLink<SimNode, SimLink>(simLinks).id((d) => d.id).distance(140))
|
|
308
|
+
.force("charge", forceManyBody().strength(-400))
|
|
309
|
+
.force("center", forceCenter(dimensions.width / 2, dimensions.height / 2))
|
|
310
|
+
.force("collide", forceCollide<SimNode>(NODE_WIDTH / 2 + 16))
|
|
311
|
+
.on("tick", () => {
|
|
312
|
+
linkEls
|
|
313
|
+
.attr("x1", (d: SimLink) => (d.source as SimNode).x ?? 0)
|
|
314
|
+
.attr("y1", (d: SimLink) => (d.source as SimNode).y ?? 0)
|
|
315
|
+
.attr("x2", (d: SimLink) => (d.target as SimNode).x ?? 0)
|
|
316
|
+
.attr("y2", (d: SimLink) => (d.target as SimNode).y ?? 0);
|
|
317
|
+
|
|
318
|
+
nodeEls
|
|
319
|
+
.attr("transform", (d: SimNode) =>
|
|
320
|
+
`translate(${(d.x ?? 0) - NODE_WIDTH / 2},${(d.y ?? 0) - NODE_HEIGHT / 2})`
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
simRef.current = sim;
|
|
325
|
+
|
|
326
|
+
// Drag behavior — lets users grab and reposition nodes
|
|
327
|
+
const dragBehavior = drag<SVGGElement, SimNode>()
|
|
328
|
+
.on("start", (_event: D3DragEvent<SVGGElement, SimNode, SimNode>, d: SimNode) => {
|
|
329
|
+
d.fx = d.x;
|
|
330
|
+
d.fy = d.y;
|
|
331
|
+
dragDistanceRef.current = 0;
|
|
332
|
+
})
|
|
333
|
+
.on("drag", (event: D3DragEvent<SVGGElement, SimNode, SimNode>, d: SimNode) => {
|
|
334
|
+
d.fx = event.x;
|
|
335
|
+
d.fy = event.y;
|
|
336
|
+
dragDistanceRef.current += Math.abs(event.dx) + Math.abs(event.dy);
|
|
337
|
+
// Only reheat simulation once we confirm an actual drag gesture
|
|
338
|
+
if (dragDistanceRef.current > DRAG_CLICK_THRESHOLD && sim.alphaTarget() === 0) {
|
|
339
|
+
sim.alphaTarget(0.3).restart();
|
|
340
|
+
}
|
|
341
|
+
})
|
|
342
|
+
.on("end", (event: D3DragEvent<SVGGElement, SimNode, SimNode>, d: SimNode) => {
|
|
343
|
+
if (!event.active) {
|
|
344
|
+
sim.alphaTarget(0);
|
|
345
|
+
}
|
|
346
|
+
// Release node so it re-settles in the force layout
|
|
347
|
+
d.fx = undefined;
|
|
348
|
+
d.fy = undefined;
|
|
349
|
+
});
|
|
350
|
+
nodeEls.call(dragBehavior);
|
|
351
|
+
|
|
352
|
+
// Zoom to fit all nodes once the force simulation has fully converged.
|
|
353
|
+
// One-shot: skip if already fitted (drag reheat would re-trigger), or if a node is selected.
|
|
354
|
+
sim.on("end", () => {
|
|
355
|
+
if (svgRef.current && zoomRef.current && simNodes.length > 0
|
|
356
|
+
&& !didAutoFitRef.current && !selectedNodeIdRef.current) {
|
|
357
|
+
didAutoFitRef.current = true;
|
|
358
|
+
const fit: ZoomToFitResult | undefined = computeZoomToFit(simNodes, dimensions);
|
|
359
|
+
if (!fit) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const { translateX, translateY, scale }: ZoomToFitResult = fit;
|
|
363
|
+
const zb: ZoomBehavior<SVGSVGElement, unknown> = zoomRef.current;
|
|
364
|
+
const t = zoomIdentity.translate(translateX, translateY).scale(scale);
|
|
365
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method -- d3 zoom API pattern
|
|
366
|
+
select(svgRef.current).transition().duration(500).call(zb.transform, t);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
return () => {
|
|
371
|
+
sim.stop();
|
|
372
|
+
};
|
|
373
|
+
}, [graphData, dimensions]);
|
|
374
|
+
|
|
375
|
+
// Update selection styling without rebuilding simulation
|
|
376
|
+
useEffect(() => {
|
|
377
|
+
if (!gRef.current || !nodeElsRef.current || !linkElsRef.current) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!selectedNodeId) {
|
|
382
|
+
// No selection — full opacity on everything
|
|
383
|
+
nodeElsRef.current.classed(styles.dimmed, false).classed(styles.selected, false);
|
|
384
|
+
linkElsRef.current.classed(styles.dimmedLink, false);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Build set of connected node IDs
|
|
389
|
+
const connectedIds: Set<string> = new Set([selectedNodeId]);
|
|
390
|
+
linkElsRef.current.each((d: SimLink) => {
|
|
391
|
+
const srcId: string = (d.source as SimNode).id;
|
|
392
|
+
const tgtId: string = (d.target as SimNode).id;
|
|
393
|
+
if (srcId === selectedNodeId || tgtId === selectedNodeId) {
|
|
394
|
+
connectedIds.add(srcId);
|
|
395
|
+
connectedIds.add(tgtId);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Update node classes
|
|
400
|
+
nodeElsRef.current
|
|
401
|
+
.classed(styles.selected, (d: SimNode) => d.id === selectedNodeId)
|
|
402
|
+
.classed(styles.dimmed, (d: SimNode) => !connectedIds.has(d.id));
|
|
403
|
+
|
|
404
|
+
// Dim unconnected links
|
|
405
|
+
linkElsRef.current
|
|
406
|
+
.classed(styles.dimmedLink, (d: SimLink) => {
|
|
407
|
+
const srcId: string = (d.source as SimNode).id;
|
|
408
|
+
const tgtId: string = (d.target as SimNode).id;
|
|
409
|
+
return !connectedIds.has(srcId) || !connectedIds.has(tgtId);
|
|
410
|
+
});
|
|
411
|
+
}, [selectedNodeId, graphData]);
|
|
412
|
+
|
|
413
|
+
// Center on selected node
|
|
414
|
+
const handleCenterOnNode = useCallback(() => {
|
|
415
|
+
if (!selectedNodeId || !simRef.current || !svgRef.current || !zoomRef.current) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const node: SimNode | undefined = simRef.current.nodes().find((n: SimNode) => n.id === selectedNodeId);
|
|
419
|
+
if (node && Number.isFinite(node.x) && Number.isFinite(node.y)) {
|
|
420
|
+
const zb: ZoomBehavior<SVGSVGElement, unknown> = zoomRef.current;
|
|
421
|
+
const t = zoomIdentity
|
|
422
|
+
.translate(dimensions.width / 2, dimensions.height / 2)
|
|
423
|
+
.scale(1.2)
|
|
424
|
+
.translate(-(node.x ?? 0), -(node.y ?? 0));
|
|
425
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method -- d3 zoom API pattern
|
|
426
|
+
select(svgRef.current).transition().duration(500).call(zb.transform, t);
|
|
427
|
+
}
|
|
428
|
+
}, [selectedNodeId, dimensions]);
|
|
429
|
+
|
|
430
|
+
useEffect(() => {
|
|
431
|
+
handleCenterOnNode();
|
|
432
|
+
}, [handleCenterOnNode]);
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<div className={styles.graphContainer} data-testid="knowledge-graph">
|
|
436
|
+
<svg
|
|
437
|
+
ref={svgRef}
|
|
438
|
+
width={dimensions.width}
|
|
439
|
+
height={dimensions.height}
|
|
440
|
+
className={styles.svg}
|
|
441
|
+
>
|
|
442
|
+
<defs>
|
|
443
|
+
<filter id="glow">
|
|
444
|
+
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
|
445
|
+
<feMerge>
|
|
446
|
+
<feMergeNode in="coloredBlur" />
|
|
447
|
+
<feMergeNode in="SourceGraphic" />
|
|
448
|
+
</feMerge>
|
|
449
|
+
</filter>
|
|
450
|
+
</defs>
|
|
451
|
+
<g ref={gRef} />
|
|
452
|
+
</svg>
|
|
453
|
+
</div>
|
|
454
|
+
);
|
|
455
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
@use '../../styles/mixins' as *;
|
|
2
|
+
|
|
3
|
+
.nav {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
height: 100%;
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.searchForm {
|
|
11
|
+
display: flex;
|
|
12
|
+
gap: 4px;
|
|
13
|
+
padding: 8px 12px;
|
|
14
|
+
border-bottom: 1px solid var(--border-default);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.searchInput {
|
|
18
|
+
flex: 1;
|
|
19
|
+
min-width: 0;
|
|
20
|
+
padding: 5px 8px;
|
|
21
|
+
border: 1px solid var(--border-default);
|
|
22
|
+
border-radius: var(--radius-sm);
|
|
23
|
+
background: var(--bg-inset);
|
|
24
|
+
color: var(--text-primary);
|
|
25
|
+
font-size: 13px;
|
|
26
|
+
|
|
27
|
+
&::placeholder {
|
|
28
|
+
color: var(--text-disabled);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
&:focus {
|
|
32
|
+
outline: none;
|
|
33
|
+
border-color: var(--accent-blue);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.searchButton {
|
|
38
|
+
@include btn-primary;
|
|
39
|
+
padding: 5px 10px;
|
|
40
|
+
font-size: 12px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.clearButton {
|
|
44
|
+
display: block;
|
|
45
|
+
width: 100%;
|
|
46
|
+
padding: 4px 12px;
|
|
47
|
+
background: none;
|
|
48
|
+
border: none;
|
|
49
|
+
border-bottom: 1px solid var(--border-default);
|
|
50
|
+
color: var(--text-secondary);
|
|
51
|
+
font-size: 12px;
|
|
52
|
+
cursor: pointer;
|
|
53
|
+
text-align: left;
|
|
54
|
+
|
|
55
|
+
&:hover {
|
|
56
|
+
color: var(--text-primary);
|
|
57
|
+
background: var(--bg-hover, rgba(255, 255, 255, 0.04));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.workspaceSelect {
|
|
62
|
+
margin: 8px 12px;
|
|
63
|
+
padding: 5px 8px;
|
|
64
|
+
border: 1px solid var(--border-default);
|
|
65
|
+
border-radius: var(--radius-sm);
|
|
66
|
+
background: var(--bg-inset);
|
|
67
|
+
color: var(--text-primary);
|
|
68
|
+
font-size: 12px;
|
|
69
|
+
|
|
70
|
+
&:focus {
|
|
71
|
+
outline: none;
|
|
72
|
+
border-color: var(--accent-blue);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.listHeader {
|
|
77
|
+
padding: 8px 12px 4px;
|
|
78
|
+
font-size: 10px;
|
|
79
|
+
font-weight: 600;
|
|
80
|
+
text-transform: uppercase;
|
|
81
|
+
letter-spacing: 0.5px;
|
|
82
|
+
color: var(--text-disabled);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.nodeList {
|
|
86
|
+
list-style: none;
|
|
87
|
+
margin: 0;
|
|
88
|
+
padding: 0;
|
|
89
|
+
overflow-y: auto;
|
|
90
|
+
flex: 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.nodeItem {
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
gap: 8px;
|
|
97
|
+
padding: 7px 12px;
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
transition: background 0.15s ease;
|
|
100
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
|
101
|
+
|
|
102
|
+
&:hover {
|
|
103
|
+
background: var(--bg-hover, rgba(255, 255, 255, 0.04));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.indicator {
|
|
108
|
+
width: 3px;
|
|
109
|
+
height: 18px;
|
|
110
|
+
border-radius: 2px;
|
|
111
|
+
flex-shrink: 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.label {
|
|
115
|
+
flex: 1;
|
|
116
|
+
font-size: 13px;
|
|
117
|
+
color: var(--text-secondary);
|
|
118
|
+
overflow: hidden;
|
|
119
|
+
text-overflow: ellipsis;
|
|
120
|
+
white-space: nowrap;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.badge {
|
|
124
|
+
font-family: var(--font-mono, monospace);
|
|
125
|
+
font-size: 9px;
|
|
126
|
+
text-transform: uppercase;
|
|
127
|
+
letter-spacing: 0.3px;
|
|
128
|
+
color: var(--text-disabled);
|
|
129
|
+
flex-shrink: 0;
|
|
130
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { expect, fn, userEvent } from "@storybook/test";
|
|
3
|
+
import { KnowledgeNav } from "./KnowledgeNav.js";
|
|
4
|
+
import { makeGraphNode, makeWorkspace } from "../../test-utils/storybook-helpers.js";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof KnowledgeNav> = {
|
|
7
|
+
title: "Grackle/Knowledge/KnowledgeNav",
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
component: KnowledgeNav,
|
|
10
|
+
decorators: [
|
|
11
|
+
(Story) => (
|
|
12
|
+
<div style={{ width: "280px", height: "500px", overflow: "auto" }}>
|
|
13
|
+
<Story />
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
],
|
|
17
|
+
args: {
|
|
18
|
+
nodes: [],
|
|
19
|
+
workspaces: [],
|
|
20
|
+
loading: false,
|
|
21
|
+
searchQuery: "",
|
|
22
|
+
onSearch: fn(),
|
|
23
|
+
onClearSearch: fn(),
|
|
24
|
+
onSelectNode: fn(),
|
|
25
|
+
onWorkspaceChange: fn(),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default meta;
|
|
30
|
+
type Story = StoryObj<typeof KnowledgeNav>;
|
|
31
|
+
|
|
32
|
+
/** Empty state shows "Nodes (0)" and the search input. */
|
|
33
|
+
export const EmptyState: Story = {
|
|
34
|
+
play: async ({ canvas }) => {
|
|
35
|
+
await expect(canvas.getByTestId("knowledge-search-input")).toBeInTheDocument();
|
|
36
|
+
await expect(canvas.getByTestId("knowledge-nav")).toHaveTextContent("Nodes (0)");
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Node list renders labels with category badges for each kind. */
|
|
41
|
+
export const WithNodes: Story = {
|
|
42
|
+
args: {
|
|
43
|
+
nodes: [
|
|
44
|
+
makeGraphNode({ id: "n-1", label: "Auth Flow", kind: "knowledge", category: "concept" }),
|
|
45
|
+
makeGraphNode({ id: "n-2", label: "DB Schema Choice", kind: "knowledge", category: "decision" }),
|
|
46
|
+
makeGraphNode({ id: "n-3", label: "Perf Insight", kind: "knowledge", category: "insight" }),
|
|
47
|
+
makeGraphNode({ id: "n-4", label: "Login Bug", kind: "reference", sourceType: "task" }),
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
play: async ({ canvas }) => {
|
|
51
|
+
await expect(canvas.getByTestId("knowledge-nav")).toHaveTextContent("Nodes (4)");
|
|
52
|
+
await expect(canvas.getByText("Auth Flow")).toBeInTheDocument();
|
|
53
|
+
await expect(canvas.getByText("DB Schema Choice")).toBeInTheDocument();
|
|
54
|
+
await expect(canvas.getByText("Perf Insight")).toBeInTheDocument();
|
|
55
|
+
await expect(canvas.getByText("Login Bug")).toBeInTheDocument();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Submitting the search form calls onSearch with the trimmed query. */
|
|
60
|
+
export const SearchSubmit: Story = {
|
|
61
|
+
play: async ({ canvas, args }) => {
|
|
62
|
+
const input = canvas.getByTestId("knowledge-search-input");
|
|
63
|
+
await userEvent.type(input, " OAuth flow ");
|
|
64
|
+
await userEvent.keyboard("{Enter}");
|
|
65
|
+
await expect(args.onSearch).toHaveBeenCalledWith("OAuth flow");
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Clear search button appears when searchQuery is non-empty and calls onClearSearch. */
|
|
70
|
+
export const ClearSearchButton: Story = {
|
|
71
|
+
args: {
|
|
72
|
+
searchQuery: "active query",
|
|
73
|
+
},
|
|
74
|
+
play: async ({ canvas, args }) => {
|
|
75
|
+
const clearButton = canvas.getByRole("button", { name: "Clear search" });
|
|
76
|
+
await expect(clearButton).toBeInTheDocument();
|
|
77
|
+
await userEvent.click(clearButton);
|
|
78
|
+
await expect(args.onClearSearch).toHaveBeenCalled();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Changing the workspace filter calls onWorkspaceChange with the selected ID. */
|
|
83
|
+
export const WorkspaceFilterChange: Story = {
|
|
84
|
+
args: {
|
|
85
|
+
workspaces: [
|
|
86
|
+
makeWorkspace({ id: "ws-alpha", name: "Alpha Workspace" }),
|
|
87
|
+
makeWorkspace({ id: "ws-beta", name: "Beta Workspace" }),
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
play: async ({ canvas, args }) => {
|
|
91
|
+
const select = canvas.getByTestId("knowledge-workspace-filter");
|
|
92
|
+
await userEvent.selectOptions(select, "ws-alpha");
|
|
93
|
+
await expect(args.onWorkspaceChange).toHaveBeenCalledWith("ws-alpha");
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Clicking a node in the list calls onSelectNode with the correct ID. */
|
|
98
|
+
export const NodeClickCallsOnSelectNode: Story = {
|
|
99
|
+
args: {
|
|
100
|
+
nodes: [
|
|
101
|
+
makeGraphNode({ id: "node-xyz", label: "Click Me" }),
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
play: async ({ canvas, args }) => {
|
|
105
|
+
await userEvent.click(canvas.getByText("Click Me"));
|
|
106
|
+
await expect(args.onSelectNode).toHaveBeenCalledWith("node-xyz");
|
|
107
|
+
},
|
|
108
|
+
};
|