From 6318bf0a2a36b103a7589509a0b3f1278fdb5bb8 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Wed, 25 Mar 2026 22:57:48 +0800 Subject: [PATCH] feat(web): create snippet from workflow --- .../snippet-info/__tests__/dropdown.spec.tsx | 6 +- .../app-sidebar/snippet-info/dropdown.tsx | 1 - .../components/snippet-create-card.tsx | 1 - .../__tests__/selection-contextmenu.spec.tsx | 106 +++++++++++++ .../workflow/create-snippet-dialog.tsx | 17 ++- .../workflow/selection-contextmenu.tsx | 140 ++++++++++++++++-- 6 files changed, 254 insertions(+), 17 deletions(-) diff --git a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx index 9907cd895a..4c24af692b 100644 --- a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx +++ b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx @@ -135,7 +135,11 @@ vi.mock('@/app/components/workflow/create-snippet-dialog', () => ({ icon: '✨', background: '#FFFFFF', }, - selectedNodeIds: [], + graph: { + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, })} > submit-edit diff --git a/web/app/components/app-sidebar/snippet-info/dropdown.tsx b/web/app/components/app-sidebar/snippet-info/dropdown.tsx index 52ab64d817..4022c6f3ec 100644 --- a/web/app/components/app-sidebar/snippet-info/dropdown.tsx +++ b/web/app/components/app-sidebar/snippet-info/dropdown.tsx @@ -158,7 +158,6 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { {isEditDialogOpen && ( { {isCreateDialogOpen && ( setIsCreateDialogOpen(false)} onConfirm={handleCreateSnippet} diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index 9855c9e9eb..c0a4624cf5 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -13,6 +13,39 @@ const mockGetNodesReadOnly = vi.fn() const mockHandleNodesCopy = vi.fn() const mockHandleNodesDelete = vi.fn() const mockHandleNodesDuplicate = vi.fn() +const mockPush = vi.fn() +const mockToastSuccess = vi.fn() +const mockToastError = vi.fn() +const mockCreateSnippetMutateAsync = vi.fn() +const mockSyncDraftWorkflow = vi.fn() + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + success: (...args: unknown[]) => mockToastSuccess(...args), + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +vi.mock('@/service/use-snippets', () => ({ + useCreateSnippetMutation: () => ({ + mutateAsync: mockCreateSnippetMutateAsync, + isPending: false, + }), +})) + +vi.mock('@/service/client', () => ({ + consoleClient: { + snippets: { + syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args), + }, + }, +})) vi.mock('../hooks', async () => { const actual = await vi.importActual('../hooks') @@ -84,6 +117,11 @@ describe('SelectionContextmenu', () => { mockHandleNodesCopy.mockReset() mockHandleNodesDelete.mockReset() mockHandleNodesDuplicate.mockReset() + mockPush.mockReset() + mockToastSuccess.mockReset() + mockToastError.mockReset() + mockCreateSnippetMutateAsync.mockReset() + mockSyncDraftWorkflow.mockReset() }) it('should not render when selectionMenu is absent', () => { @@ -206,6 +244,74 @@ describe('SelectionContextmenu', () => { expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1) }) + it('should create a snippet with the selected graph and redirect to the snippet detail page', async () => { + mockCreateSnippetMutateAsync.mockResolvedValue({ id: 'snippet-123' }) + mockSyncDraftWorkflow.mockResolvedValue({ result: 'success' }) + + const nodes = [ + createNode({ id: 'n1', selected: true, position: { x: 120, y: 60 }, width: 40, height: 20 }), + createNode({ id: 'n2', selected: true, position: { x: 260, y: 120 }, width: 60, height: 30 }), + createNode({ id: 'n3', selected: false, position: { x: 500, y: 300 }, width: 40, height: 20 }), + ] + const edges = [ + createEdge({ id: 'e1', source: 'n1', target: 'n2' }), + createEdge({ id: 'e2', source: 'n2', target: 'n3' }), + ] + + const { store } = renderSelectionMenu({ nodes, edges }) + + act(() => { + store.setState({ selectionMenu: { left: 120, top: 120 } }) + }) + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-createSnippet')) + fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), { + target: { value: 'My snippet' }, + }) + fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i })) + + await waitFor(() => { + expect(mockCreateSnippetMutateAsync).toHaveBeenCalledWith({ + body: expect.objectContaining({ + name: 'My snippet', + }), + }) + }) + + expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({ + params: { snippetId: 'snippet-123' }, + body: { + graph: { + nodes: [ + expect.objectContaining({ + id: 'n1', + position: { x: 0, y: 0 }, + selected: false, + data: expect.objectContaining({ selected: false }), + }), + expect.objectContaining({ + id: 'n2', + position: { x: 140, y: 60 }, + selected: false, + data: expect.objectContaining({ selected: false }), + }), + ], + edges: [ + expect.objectContaining({ + id: 'e1', + source: 'n1', + target: 'n2', + selected: false, + }), + ], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + }, + }) + expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess') + expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate') + }) + it('should distribute selected nodes horizontally', async () => { const nodes = [ createNode({ id: 'n1', selected: true, position: { x: 0, y: 10 }, width: 20, height: 20 }), diff --git a/web/app/components/workflow/create-snippet-dialog.tsx b/web/app/components/workflow/create-snippet-dialog.tsx index 2da326bc27..1c0e84a50d 100644 --- a/web/app/components/workflow/create-snippet-dialog.tsx +++ b/web/app/components/workflow/create-snippet-dialog.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { AppIconSelection } from '@/app/components/base/app-icon-picker' +import type { SnippetCanvasData } from '@/models/snippet' import { useKeyPress } from 'ahooks' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -17,7 +18,7 @@ export type CreateSnippetDialogPayload = { name: string description: string icon: AppIconSelection - selectedNodeIds: string[] + graph: SnippetCanvasData } export type CreateSnippetDialogInitialValue = { @@ -28,7 +29,7 @@ export type CreateSnippetDialogInitialValue = { type CreateSnippetDialogProps = { isOpen: boolean - selectedNodeIds: string[] + selectedGraph?: SnippetCanvasData onClose: () => void onConfirm: (payload: CreateSnippetDialogPayload) => void isSubmitting?: boolean @@ -43,9 +44,15 @@ const defaultIcon: AppIconSelection = { background: '#FFEAD5', } +const defaultGraph: SnippetCanvasData = { + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, +} + const CreateSnippetDialog: FC = ({ isOpen, - selectedNodeIds, + selectedGraph, onClose, onConfirm, isSubmitting = false, @@ -82,11 +89,11 @@ const CreateSnippetDialog: FC = ({ name: trimmedName, description: trimmedDescription, icon, - selectedNodeIds, + graph: selectedGraph ?? defaultGraph, } onConfirm(payload) - }, [description, icon, name, onConfirm, selectedNodeIds]) + }, [description, icon, name, onConfirm, selectedGraph]) useKeyPress(['meta.enter', 'ctrl.enter'], () => { if (!isOpen) diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 435e25b49c..35bc21b435 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -1,5 +1,7 @@ import type { ComponentType } from 'react' -import type { Node } from './types' +import type { CreateSnippetDialogPayload } from './create-snippet-dialog' +import type { Edge, Node } from './types' +import type { SnippetCanvasData } from '@/models/snippet' import { RiAlignItemBottomLine, RiAlignItemHorizontalCenterLine, @@ -25,6 +27,10 @@ import { ContextMenuItem, ContextMenuSeparator, } from '@/app/components/base/ui/context-menu' +import { toast } from '@/app/components/base/ui/toast' +import { useRouter } from '@/next/navigation' +import { consoleClient } from '@/service/client' +import { useCreateSnippetMutation } from '@/service/use-snippets' import { cn } from '@/utils/classnames' import CreateSnippetDialog from './create-snippet-dialog' import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks' @@ -77,6 +83,7 @@ type ActionMenuItem = { const MENU_WIDTH = 240 const MENU_HEIGHT = 240 +const DEFAULT_SNIPPET_VIEWPORT: SnippetCanvasData['viewport'] = { x: 0, y: 0, zoom: 1 } const alignMenuItems: AlignMenuItem[] = [ { alignType: AlignType.Left, icon: RiAlignItemLeftLine, translationKey: 'operator.alignLeft' }, @@ -257,14 +264,88 @@ const distributeNodes = ( }) } +const getSelectedSnippetGraph = ( + nodes: Node[], + edges: Edge[], + selectedNodes: Node[], +): SnippetCanvasData => { + const includedNodeIds = new Set(selectedNodes.map(node => node.id)) + + let shouldExpand = true + while (shouldExpand) { + shouldExpand = false + + nodes.forEach((node) => { + if (!includedNodeIds.has(node.id)) + return + + if (node.parentId && !includedNodeIds.has(node.parentId)) { + includedNodeIds.add(node.parentId) + shouldExpand = true + } + + node.data._children?.forEach((child) => { + if (!includedNodeIds.has(child.nodeId)) { + includedNodeIds.add(child.nodeId) + shouldExpand = true + } + }) + }) + } + + const rootNodes = nodes.filter(node => includedNodeIds.has(node.id) && (!node.parentId || !includedNodeIds.has(node.parentId))) + 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 + + 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, + selected: false, + })), + viewport: DEFAULT_SNIPPET_VIEWPORT, + } +} + const SelectionContextmenu = () => { const { t } = useTranslation() + const { push } = useRouter() + const createSnippetMutation = useCreateSnippetMutation() const { getNodesReadOnly } = useNodesReadOnly() const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions() const { handleSelectionContextmenuCancel } = useSelectionInteractions() const selectionMenu = useStore(s => s.selectionMenu) const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false) - const [selectedNodeIdsSnapshot, setSelectedNodeIdsSnapshot] = useState([]) + const [isCreatingSnippet, setIsCreatingSnippet] = useState(false) + const [selectedGraphSnapshot, setSelectedGraphSnapshot] = useState() // Access React Flow methods const store = useStoreApi() @@ -316,16 +397,58 @@ const SelectionContextmenu = () => { if (isAddToSnippetDisabled) return - setSelectedNodeIdsSnapshot(selectedNodes.map(node => node.id)) + const nodes = store.getState().getNodes() + const { edges } = store.getState() + + setSelectedGraphSnapshot(getSelectedSnippetGraph(nodes, edges, selectedNodes)) setIsCreateSnippetDialogOpen(true) handleSelectionContextmenuCancel() - }, [handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes]) + }, [handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes, store]) const handleCloseCreateSnippetDialog = useCallback(() => { setIsCreateSnippetDialogOpen(false) - setSelectedNodeIdsSnapshot([]) + setSelectedGraphSnapshot(undefined) }, []) + const handleCreateSnippet = useCallback(async ({ + name, + description, + icon, + graph, + }: CreateSnippetDialogPayload) => { + setIsCreatingSnippet(true) + + try { + const snippet = await createSnippetMutation.mutateAsync({ + body: { + name, + description: description || undefined, + icon_info: { + icon: icon.type === 'emoji' ? icon.icon : icon.fileId, + icon_type: icon.type, + icon_background: icon.type === 'emoji' ? icon.background : undefined, + icon_url: icon.type === 'image' ? icon.url : undefined, + }, + }, + }) + + await consoleClient.snippets.syncDraftWorkflow({ + params: { snippetId: snippet.id }, + body: { graph }, + }) + + toast.success(t('snippet.createSuccess', { ns: 'workflow' })) + handleCloseCreateSnippetDialog() + push(`/snippets/${snippet.id}/orchestrate`) + } + catch (error) { + toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' })) + } + finally { + setIsCreatingSnippet(false) + } + }, [createSnippetMutation, handleCloseCreateSnippetDialog, push, t]) + const menuActions = useMemo(() => [ { action: 'createSnippet', @@ -500,11 +623,10 @@ const SelectionContextmenu = () => { {isCreateSnippetDialogOpen && ( { - void payload - }} + onConfirm={handleCreateSnippet} /> )}