diff --git a/corpus/frontend/reactflow/background.yaml b/corpus/frontend/reactflow/background.yaml new file mode 100644 index 0000000..9bd785a --- /dev/null +++ b/corpus/frontend/reactflow/background.yaml @@ -0,0 +1,41 @@ +name: Background +kind: component +description: >- + Renders different background types common in node-based UIs. Comes with three + variants: lines, dots, and cross. +importPath: "import { Background, BackgroundVariant } from '@xyflow/react'" +props: + - name: variant + type: BackgroundVariant + description: Background pattern type. + default: BackgroundVariant.Dots + - name: gap + type: "number | [number, number]" + description: Gap between pattern elements. + default: "20" + - name: size + type: number + description: Size of pattern dots/lines. + default: "1" + - name: color + type: string + description: Pattern color. + - name: lineWidth + type: number + description: Stroke width for lines/cross variant. + default: "1" +usage: | + + + +examples: + - title: Cross pattern background + category: styling + code: | + import { Background, BackgroundVariant } from '@xyflow/react'; + + +relatedApis: + - ReactFlow + - MiniMap + - Controls diff --git a/corpus/frontend/reactflow/controls.yaml b/corpus/frontend/reactflow/controls.yaml new file mode 100644 index 0000000..aed07c1 --- /dev/null +++ b/corpus/frontend/reactflow/controls.yaml @@ -0,0 +1,46 @@ +name: Controls +kind: component +description: >- + Renders a small panel with zoom in, zoom out, fit view, and lock viewport + buttons. +importPath: "import { Controls, ControlButton } from '@xyflow/react'" +props: + - name: showZoom + type: boolean + description: Show zoom buttons. + default: "true" + - name: showFitView + type: boolean + description: Show fit view button. + default: "true" + - name: showInteractive + type: boolean + description: Show lock button. + default: "true" + - name: position + type: PanelPosition + description: Corner position. + default: "'bottom-left'" + - name: orientation + type: "'horizontal' | 'vertical'" + description: Layout direction. + default: "'vertical'" +usage: | + + + +examples: + - title: Custom control button + category: interaction + code: | + import { Controls, ControlButton } from '@xyflow/react'; + + + console.log('custom action')}> + + + +relatedApis: + - ReactFlow + - ControlButton + - Panel diff --git a/corpus/frontend/reactflow/handle.yaml b/corpus/frontend/reactflow/handle.yaml new file mode 100644 index 0000000..b2b57f9 --- /dev/null +++ b/corpus/frontend/reactflow/handle.yaml @@ -0,0 +1,71 @@ +name: Handle +kind: component +description: >- + Used in custom nodes to define connection points. Handles can be sources + (outgoing) or targets (incoming). +importPath: "import { Handle, Position } from '@xyflow/react'" +props: + - name: type + type: "'source' | 'target'" + description: Whether this is an input or output handle. + default: "'source'" + - name: position + type: Position + description: "Side of the node: Position.Top, Bottom, Left, Right." + default: Position.Top + - name: id + type: string + description: Handle ID, needed when a node has multiple handles of the same type. + - name: isConnectable + type: boolean + description: Whether connections can be made to/from this handle. + default: "true" + - name: isConnectableStart + type: boolean + description: Whether a connection can start from this handle. + default: "true" + - name: isConnectableEnd + type: boolean + description: Whether a connection can end on this handle. + default: "true" + - name: isValidConnection + type: IsValidConnection + description: Custom validation logic for connections to this handle. + - name: onConnect + type: OnConnect + description: Callback when connection is made to this handle. +usage: | + import { Handle, Position } from '@xyflow/react'; + + function CustomNode({ data }) { + return ( + <> + +
{data.label}
+ + + ); + } +examples: + - title: Multiple handles + category: custom-nodes + code: | + function MultiHandleNode({ data }) { + return ( +
+ + +
{data.label}
+ + +
+ ); + } +tips: + - Use the id prop when a node has multiple source or target handles. + - Prefer isValidConnection on over per-handle validation for performance. + - Add 'nodrag' class to interactive elements inside a node to prevent drag when clicking them. +relatedApis: + - ReactFlow + - NodeResizer + - NodeToolbar diff --git a/corpus/frontend/reactflow/index.yaml b/corpus/frontend/reactflow/index.yaml index b005965..d3bb54e 100644 --- a/corpus/frontend/reactflow/index.yaml +++ b/corpus/frontend/reactflow/index.yaml @@ -2,3 +2,13 @@ namespace: frontend.reactflow apis: reactflow: file: reactflow.yaml + handle: + file: handle.yaml + background: + file: background.yaml + controls: + file: controls.yaml + minimap: + file: minimap.yaml + usereactflow: + file: usereactflow.yaml diff --git a/corpus/frontend/reactflow/minimap.yaml b/corpus/frontend/reactflow/minimap.yaml new file mode 100644 index 0000000..9c9d435 --- /dev/null +++ b/corpus/frontend/reactflow/minimap.yaml @@ -0,0 +1,41 @@ +name: MiniMap +kind: component +description: >- + Renders an overview of your flow. Each node appears as an SVG element showing + the current viewport position relative to the full flow. +importPath: "import { MiniMap } from '@xyflow/react'" +props: + - name: nodeColor + type: "string | ((node: Node) => string)" + description: Color of minimap nodes. + - name: nodeStrokeColor + type: "string | ((node: Node) => string)" + description: Stroke color of minimap nodes. + - name: nodeStrokeWidth + type: number + description: Stroke width. + default: "2" + - name: maskColor + type: string + description: Color of the area outside the viewport. + - name: position + type: PanelPosition + description: Corner position. + default: "'bottom-right'" + - name: pannable + type: boolean + description: Allow panning via minimap. + default: "false" + - name: zoomable + type: boolean + description: Allow zooming via minimap. + default: "false" +usage: | + + n.type === 'input' ? '#6366f1' : '#94a3b8'} pannable zoomable /> + +examples: [] +relatedApis: + - ReactFlow + - Background + - Controls diff --git a/corpus/frontend/reactflow/usereactflow.yaml b/corpus/frontend/reactflow/usereactflow.yaml new file mode 100644 index 0000000..f1f7a29 --- /dev/null +++ b/corpus/frontend/reactflow/usereactflow.yaml @@ -0,0 +1,48 @@ +name: useReactFlow +kind: hook +description: >- + Returns a ReactFlowInstance to update nodes/edges, manipulate the viewport, + or query flow state. Does NOT cause re-renders on state changes. +importPath: "import { useReactFlow } from '@xyflow/react'" +returns: ReactFlowInstance +usage: | + const { getNodes, setNodes, addNodes, getEdges, setEdges, addEdges, + fitView, zoomIn, zoomOut, getViewport, setViewport, + screenToFlowPosition, deleteElements, updateNode, updateNodeData, + getIntersectingNodes, toObject } = useReactFlow(); +examples: + - title: Add node on button click + category: interaction + code: | + function AddNodeButton() { + const { addNodes, screenToFlowPosition } = useReactFlow(); + const onClick = () => { + addNodes({ + id: crypto.randomUUID(), + position: screenToFlowPosition({ x: 200, y: 200 }), + data: { label: 'New Node' }, + }); + }; + return ; + } + - title: Delete selected elements + category: interaction + code: | + function DeleteButton() { + const { deleteElements, getNodes, getEdges } = useReactFlow(); + const onClick = async () => { + const selectedNodes = getNodes().filter((n) => n.selected); + const selectedEdges = getEdges().filter((e) => e.selected); + await deleteElements({ nodes: selectedNodes, edges: selectedEdges }); + }; + return ; + } +tips: + - Must be used inside or . + - Unlike useNodes/useEdges, this hook won't cause re-renders on state changes. Query state on demand. + - "Pass useReactFlow() as dependency to useCallback/useEffect - it's not initialized on first render." +relatedApis: + - ReactFlowProvider + - ReactFlowInstance + - useNodes + - useEdges diff --git a/src/plugins/reactflow/tools/get-api.ts b/src/plugins/reactflow/tools/get-api.ts index 0bc15ec..8c99119 100644 --- a/src/plugins/reactflow/tools/get-api.ts +++ b/src/plugins/reactflow/tools/get-api.ts @@ -16,11 +16,7 @@ type CorpusIndex = { type CorpusNamespaceIndex = { namespace?: string; - apis?: { - reactflow?: { - file?: string; - }; - }; + apis?: Record; }; type CorpusApiEntry = { @@ -36,21 +32,27 @@ type CorpusApiEntry = { relatedApis?: string[]; }; +type LoadedCorpusApi = { + api: Parameters[0]; + source: string; +}; + +type ReactFlowExample = Parameters[0]["examples"][number]; + const moduleDir = dirname(fileURLToPath(import.meta.url)); const corpusRoot = join(moduleDir, "../../../../corpus"); const corpusNamespace = "frontend.reactflow"; -const corpusApiName = "ReactFlow"; -type ReactFlowExample = Parameters[0]["examples"][number]; -let cachedCorpusReactFlowApi: { api: Parameters[0]; source: string } | null | undefined; +const cachedCorpusApis = new Map(); function normalizeApiName(name: string): string { return name.toLowerCase().replace(/[<>/()]/g, "").trim(); } -function loadCorpusReactFlowApi(): { api: Parameters[0]; source: string } | null { - if (cachedCorpusReactFlowApi !== undefined) { - return cachedCorpusReactFlowApi; +function loadCorpusApi(name: string): LoadedCorpusApi | null { + const key = normalizeApiName(name); + if (cachedCorpusApis.has(key)) { + return cachedCorpusApis.get(key) ?? null; } try { @@ -58,35 +60,35 @@ function loadCorpusReactFlowApi(): { api: Parameters[ const index = YAML.parse(indexRaw) as CorpusIndex | null; const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index; if (!namespaceIndexPath) { - cachedCorpusReactFlowApi = null; - return cachedCorpusReactFlowApi; + cachedCorpusApis.set(key, null); + return null; } const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8"); const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null; - const apiPath = namespaceIndex?.apis?.reactflow?.file; + const apiPath = namespaceIndex?.apis?.[key]?.file; if (!apiPath) { - cachedCorpusReactFlowApi = null; - return cachedCorpusReactFlowApi; + cachedCorpusApis.set(key, null); + return null; } const apiRaw = readFileSync(join(corpusRoot, "frontend/reactflow", apiPath), "utf8"); const api = YAML.parse(apiRaw) as CorpusApiEntry | null; if ( !api || - normalizeApiName(api.name ?? "") !== normalizeApiName(corpusApiName) || + normalizeApiName(api.name ?? "") !== key || !api.usage || !api.importPath || !api.description || !api.kind ) { - cachedCorpusReactFlowApi = null; - return cachedCorpusReactFlowApi; + cachedCorpusApis.set(key, null); + return null; } - cachedCorpusReactFlowApi = { + const loaded: LoadedCorpusApi = { api: { - name: api.name ?? corpusApiName, + name: api.name ?? name, kind: api.kind as Parameters[0]["kind"], description: api.description, importPath: api.importPath, @@ -108,26 +110,14 @@ function loadCorpusReactFlowApi(): { api: Parameters[ }, source: corpusNamespace, }; - return cachedCorpusReactFlowApi ?? null; + cachedCorpusApis.set(key, loaded); + return loaded; } catch { - cachedCorpusReactFlowApi = null; + cachedCorpusApis.set(key, null); return null; } } -function getReactFlowApi(name: string) { - if (normalizeApiName(name) !== normalizeApiName(corpusApiName)) { - return getApiByName(name, ALL_APIS); - } - - const corpusEntry = loadCorpusReactFlowApi(); - if (corpusEntry) { - return corpusEntry.api; - } - - return getApiByName(name, ALL_APIS); -} - export function register(server: McpServer): void { server.tool( "reactflow_get_api", @@ -140,8 +130,8 @@ export function register(server: McpServer): void { ), }, async ({ name }) => { - const corpusEntry = normalizeApiName(name) === normalizeApiName(corpusApiName) ? loadCorpusReactFlowApi() : null; - const api = getReactFlowApi(name); + const corpusEntry = loadCorpusApi(name); + const api = corpusEntry?.api ?? getApiByName(name, ALL_APIS); if (!api) { const suggestions = searchApis(name, ALL_APIS) .slice(0, 5) diff --git a/tests/reactflow-corpus-backed-tools-behaviour.test.ts b/tests/reactflow-corpus-backed-tools-behaviour.test.ts index 491d417..4e104ad 100644 --- a/tests/reactflow-corpus-backed-tools-behaviour.test.ts +++ b/tests/reactflow-corpus-backed-tools-behaviour.test.ts @@ -13,11 +13,55 @@ test("reactflow_get_api prefers corpus metadata for ReactFlow", async () => { expect(text).toContain("import { ReactFlow } from '@xyflow/react'"); }); -test("reactflow_get_api falls back to in-file data for non-corpus APIs", async () => { +test("reactflow_get_api prefers corpus metadata for Handle", async () => { const result = await reactFlowGetApi.invoke({ name: "Handle" }); const text = extractTextContent(result); expect(text).toContain("# Handle (component)"); + expect(text).toContain("**Corpus Source:** frontend.reactflow"); expect(text).toContain("import { Handle, Position } from '@xyflow/react'"); +}); + +test("reactflow_get_api prefers corpus metadata for Background", async () => { + const result = await reactFlowGetApi.invoke({ name: "Background" }); + const text = extractTextContent(result); + + expect(text).toContain("# Background (component)"); + expect(text).toContain("**Corpus Source:** frontend.reactflow"); + expect(text).toContain("import { Background, BackgroundVariant } from '@xyflow/react'"); +}); + +test("reactflow_get_api prefers corpus metadata for Controls", async () => { + const result = await reactFlowGetApi.invoke({ name: "Controls" }); + const text = extractTextContent(result); + + expect(text).toContain("# Controls (component)"); + expect(text).toContain("**Corpus Source:** frontend.reactflow"); + expect(text).toContain("import { Controls, ControlButton } from '@xyflow/react'"); +}); + +test("reactflow_get_api prefers corpus metadata for MiniMap", async () => { + const result = await reactFlowGetApi.invoke({ name: "MiniMap" }); + const text = extractTextContent(result); + + expect(text).toContain("# MiniMap (component)"); + expect(text).toContain("**Corpus Source:** frontend.reactflow"); + expect(text).toContain("import { MiniMap } from '@xyflow/react'"); +}); + +test("reactflow_get_api prefers corpus metadata for useReactFlow", async () => { + const result = await reactFlowGetApi.invoke({ name: "useReactFlow" }); + const text = extractTextContent(result); + + expect(text).toContain("# useReactFlow (hook)"); + expect(text).toContain("**Corpus Source:** frontend.reactflow"); + expect(text).toContain("fitView, zoomIn, zoomOut"); +}); + +test("reactflow_get_api falls back to in-file data for non-corpus APIs", async () => { + const result = await reactFlowGetApi.invoke({ name: "NodeResizer" }); + const text = extractTextContent(result); + + expect(text).toContain("# NodeResizer (component)"); expect(text).not.toContain("**Corpus Source:** frontend.reactflow"); });