diff --git a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx index ed0ce3987d..05ed901d0b 100644 --- a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx @@ -1,6 +1,7 @@ import type { WorkflowProps } from '@/app/components/workflow' import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { PipelineInputVarType } from '@/models/pipeline' import SnippetMain from '../snippet-main' @@ -191,7 +192,7 @@ const payload: SnippetDetailPayload = { } const renderSnippetMain = () => { - return render( + return renderWorkflowComponent( { beforeEach(() => { vi.clearAllMocks() mockSyncInputFieldsDraft.mockResolvedValue(undefined) - mockPublishSnippetMutateAsync.mockResolvedValue(undefined) + mockPublishSnippetMutateAsync.mockResolvedValue({ created_at: 1_744_000_000 }) capturedHooksStore = undefined snippetDetailStoreState = { editingField: null, diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index a52f9b17d3..6ca94c2804 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -300,7 +300,14 @@ describe('SelectionContextmenu', () => { createEdge({ id: 'e2', source: 'n2', target: 'n3' }), ] - const { store } = renderSelectionMenu({ nodes, edges }) + const { store } = renderSelectionMenu({ + nodes, + edges, + initialStoreState: { + workflowCanvasWidth: 800, + workflowCanvasHeight: 600, + }, + }) act(() => { store.setState({ selectionMenu: { clientX: 120, clientY: 120 } }) @@ -348,7 +355,7 @@ describe('SelectionContextmenu', () => { selected: false, }), ], - viewport: { x: 0, y: 0, zoom: 1 }, + viewport: { x: 300, y: 255, zoom: 1 }, }, }, }) diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 35e01f34d2..375330bdcb 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -18,7 +18,7 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { useStore as useReactFlowStore, useStoreApi } from 'reactflow' +import { getNodesBounds, useStore as useReactFlowStore, useStoreApi } from 'reactflow' import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access' import { useRouter } from '@/next/navigation' @@ -67,6 +67,7 @@ type ActionMenuItem = { } const DEFAULT_SNIPPET_VIEWPORT: SnippetCanvasData['viewport'] = { x: 0, y: 0, zoom: 1 } +const SNIPPET_VIEWPORT_PADDING = 100 const alignMenuItems: AlignMenuItem[] = [ { alignType: AlignType.Left, icon: 'i-ri-align-item-left-line', translationKey: 'operator.alignLeft' }, @@ -228,6 +229,10 @@ const getSelectedSnippetGraph = ( nodes: Node[], edges: Edge[], selectedNodes: Node[], + canvasSize?: { + width?: number + height?: number + }, ): SnippetCanvasData => { const includedNodeIds = new Set(selectedNodes.map(node => node.id)) @@ -257,41 +262,74 @@ const getSelectedSnippetGraph = ( const minRootX = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.x)) : 0 const minRootY = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.y)) : 0 - return { - nodes: nodes - .filter(node => includedNodeIds.has(node.id)) - .map((node) => { - const isRootNode = !node.parentId || !includedNodeIds.has(node.parentId) - const nextPosition = isRootNode - ? { x: node.position.x - minRootX, y: node.position.y - minRootY } - : node.position + const snippetNodes = nodes + .filter(node => includedNodeIds.has(node.id)) + .map((node) => { + const isRootNode = !node.parentId || !includedNodeIds.has(node.parentId) + const nextPosition = isRootNode + ? { x: node.position.x - minRootX, y: node.position.y - minRootY } + : node.position - return { - ...node, - position: nextPosition, - positionAbsolute: node.positionAbsolute - ? (isRootNode - ? { - x: node.positionAbsolute.x - minRootX, - y: node.positionAbsolute.y - minRootY, - } - : node.positionAbsolute) - : undefined, - selected: false, - data: { - ...node.data, - selected: false, - _children: node.data._children?.filter(child => includedNodeIds.has(child.nodeId)), - }, - } - }), - edges: edges - .filter(edge => includedNodeIds.has(edge.source) && includedNodeIds.has(edge.target)) - .map(edge => ({ - ...edge, + return { + ...node, + position: nextPosition, + positionAbsolute: node.positionAbsolute + ? (isRootNode + ? { + x: node.positionAbsolute.x - minRootX, + y: node.positionAbsolute.y - minRootY, + } + : node.positionAbsolute) + : undefined, selected: false, - })), - viewport: DEFAULT_SNIPPET_VIEWPORT, + data: { + ...node.data, + selected: false, + _children: node.data._children?.filter(child => includedNodeIds.has(child.nodeId)), + }, + } + }) + const snippetEdges = edges + .filter(edge => includedNodeIds.has(edge.source) && includedNodeIds.has(edge.target)) + .map(edge => ({ + ...edge, + selected: false, + })) + + const viewportWidth = canvasSize?.width + const viewportHeight = canvasSize?.height + const hasCanvasSize = !!viewportWidth && !!viewportHeight + + const viewport = (() => { + if (!hasCanvasSize || !snippetNodes.length) + return DEFAULT_SNIPPET_VIEWPORT + + const bounds = getNodesBounds(snippetNodes) + const paddedWidth = bounds.width + SNIPPET_VIEWPORT_PADDING + const paddedHeight = bounds.height + SNIPPET_VIEWPORT_PADDING + const zoom = Math.min( + viewportWidth / paddedWidth, + viewportHeight / paddedHeight, + 1, + ) + + if (!Number.isFinite(zoom) || zoom <= 0) + return DEFAULT_SNIPPET_VIEWPORT + + const centerX = bounds.x + bounds.width / 2 + const centerY = bounds.y + bounds.height / 2 + + return { + x: viewportWidth / 2 - centerX * zoom, + y: viewportHeight / 2 - centerY * zoom, + zoom, + } + })() + + return { + nodes: snippetNodes, + edges: snippetEdges, + viewport, } } @@ -356,11 +394,18 @@ const SelectionContextmenu = () => { const nodes = store.getState().getNodes() const { edges } = store.getState() + const { + workflowCanvasWidth, + workflowCanvasHeight, + } = workflowStore.getState() - setSelectedGraphSnapshot(getSelectedSnippetGraph(nodes, edges, selectedNodes)) + setSelectedGraphSnapshot(getSelectedSnippetGraph(nodes, edges, selectedNodes, { + width: workflowCanvasWidth, + height: workflowCanvasHeight, + })) setIsCreateSnippetDialogOpen(true) handleSelectionContextmenuCancel() - }, [canAccessSnippetsAndEvaluation, handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes, store]) + }, [canAccessSnippetsAndEvaluation, handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes, store, workflowStore]) const handleCloseCreateSnippetDialog = useCallback(() => { setIsCreateSnippetDialogOpen(false)