diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 47f328d50d6..f5ac48c3a46 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -421,16 +421,18 @@ describe('List', () => { expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument() }) - it('should render sort filter before search and hide the snippets link', () => { + it('should render sort filter before search and the snippets link', () => { renderList() const sortButton = screen.getByRole('button', { name: 'Sort by Last modified' }) const searchInput = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }) + const snippetsLink = screen.getByRole('link', { name: 'app.studio.viewSnippets' }) const createButton = screen.getByRole('button', { name: 'common.operation.create' }) + expect(snippetsLink).toHaveAttribute('href', '/snippets') expect(sortButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - expect(searchInput.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument() + expect(searchInput.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(snippetsLink.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() }) it('should render app cards when apps exist', () => { diff --git a/web/app/components/apps/app-list-header-filters.tsx b/web/app/components/apps/app-list-header-filters.tsx index ceccb7575f6..df32f57d7ba 100644 --- a/web/app/components/apps/app-list-header-filters.tsx +++ b/web/app/components/apps/app-list-header-filters.tsx @@ -8,6 +8,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { useTranslation } from 'react-i18next' import { SearchInput } from '@/app/components/base/search-input' import { TagFilter } from '@/features/tag-management/components/tag-filter' +import Link from '@/next/link' import { AppSortFilter } from './app-sort-filter' import { AppTypeFilter } from './app-type-filter' import CreatorsFilter from './creators-filter' @@ -70,6 +71,12 @@ export function AppListHeaderFilters({ />
+ + {t('studio.viewSnippets', { ns: 'app' })} + {showCreateButton && ( ({ + value: ['snippets.create_and_modify'] as string[], +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({ + workspacePermissionKeys: mockWorkspacePermissionKeys.value, + }), +})) + +vi.mock('@/app/components/snippets/hooks/use-create-snippet', async () => { + const React = await vi.importActual('react') + + return { + useCreateSnippet: () => { + const [isOpen, setIsOpen] = React.useState(false) + + return { + createSnippetMutation: { isPending: false }, + handleCloseCreateSnippetDialog: () => setIsOpen(false), + handleCreateSnippet: mockHandleCreateSnippet, + handleOpenCreateSnippetDialog: () => setIsOpen(true), + isCreateSnippetDialogOpen: isOpen, + isCreatingSnippet: false, + } + }, + } +}) + +vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ + default: (props: { + isOpen: boolean + selectedGraph?: { nodes: Node[], edges: Edge[], viewport: { x: number, y: number, zoom: number } } + inputFields?: Array<{ variable: string }> + }) => { + mockCreateSnippetDialogRender(props) + + return props.isOpen ?
: null + }, +})) vi.mock('../hooks', async () => { const actual = await vi.importActual('../hooks') @@ -98,6 +142,9 @@ describe('SelectionContextmenu', () => { mockHandleNodesCopy.mockReset() mockHandleNodesDuplicate.mockReset() mockHandleNodesDelete.mockReset() + mockHandleCreateSnippet.mockReset() + mockCreateSnippetDialogRender.mockReset() + mockWorkspacePermissionKeys.value = ['snippets.create_and_modify'] }) it('should not render when selection context menu target is absent', () => { @@ -156,7 +203,41 @@ describe('SelectionContextmenu', () => { expect(store.getState().contextMenuTarget).toBeUndefined() }) - it('should hide create snippet action for selected nodes', async () => { + it('should open create snippet dialog with selected graph from the top menu item', async () => { + const nodes = [ + createNode({ id: 'n1', selected: true, width: 80, height: 40 }), + createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), + createNode({ id: 'n3', selected: false, position: { x: 260, y: 0 }, width: 80, height: 40 }), + ] + const edges = [ + createEdge({ source: 'n1', target: 'n2' }), + createEdge({ source: 'n2', target: 'n3' }), + ] + const { store } = renderSelectionMenu({ nodes, edges }) + + act(() => { + store.setState({ contextMenuTarget: { type: 'selection' } }) + }) + + fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })) + + expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument() + expect(store.getState().contextMenuTarget).toBeUndefined() + + const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0] + expect(dialogProps.selectedGraph.nodes.map((node: Node) => node.id)).toEqual(['n1', 'n2']) + expect(dialogProps.selectedGraph.nodes.every((node: Node) => node.selected === false)).toBe(true) + expect(dialogProps.selectedGraph.edges).toHaveLength(1) + expect(dialogProps.selectedGraph.viewport).toEqual({ x: 490, y: 380, zoom: 1 }) + expect(dialogProps.selectedGraph.edges[0]).toEqual(expect.objectContaining({ + source: 'n1', + target: 'n2', + selected: false, + })) + }) + + it('should hide create snippet action without snippets create-and-modify permission', async () => { + mockWorkspacePermissionKeys.value = [] const nodes = [ createNode({ id: 'n1', selected: true, width: 80, height: 40 }), createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), @@ -171,7 +252,76 @@ describe('SelectionContextmenu', () => { expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument() }) expect(screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })).not.toBeInTheDocument() - expect(screen.queryByTestId('create-snippet-dialog')).not.toBeInTheDocument() + }) + + it('should add input fields for variable references outside of the selected graph', async () => { + const nodes = [ + createNode({ + id: 'n1', + selected: true, + width: 80, + height: 40, + data: { + prompt_template: 'Use {{#source-node.topic#}} and {{#n2.answer#}}', + query_variable_selector: ['source-node', 'topic'], + env_reference: '{{#env.API_KEY#}}', + }, + }), + createNode({ + id: 'n2', + selected: true, + position: { x: 140, y: 0 }, + width: 80, + height: 40, + }), + ] + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ contextMenuTarget: { type: 'selection' } }) + }) + + fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })) + + const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0] + expect(dialogProps.inputFields).toEqual([ + { + label: 'topic', + variable: 'topic', + type: PipelineInputVarType.textInput, + required: true, + }, + { + label: 'API_KEY', + variable: 'API_KEY', + type: PipelineInputVarType.textInput, + required: true, + }, + ]) + expect(dialogProps.selectedGraph.nodes[0].data.prompt_template).toBe('Use {{#start.topic#}} and {{#n2.answer#}}') + expect(dialogProps.selectedGraph.nodes[0].data.query_variable_selector).toEqual(['start', 'topic']) + expect(dialogProps.selectedGraph.nodes[0].data.env_reference).toBe('{{#start.API_KEY#}}') + }) + + it.each([ + BlockEnum.Answer, + BlockEnum.End, + BlockEnum.Start, + ])('should hide create snippet when selection contains %s node', async (nodeType) => { + const nodes = [ + createNode({ id: 'n1', selected: true, width: 80, height: 40, data: { type: nodeType } }), + createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), + ] + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ contextMenuTarget: { type: 'selection' } }) + }) + + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument() + }) + expect(screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })).not.toBeInTheDocument() }) it('should stay hidden when only one node is selected', async () => { diff --git a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx index b7248dcb90a..96fc6064818 100644 --- a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx @@ -106,7 +106,6 @@ describe('NodeSelector', () => { await user.click(trigger) const searchInput = screen.getByPlaceholderText('workflow.tabs.searchBlock') - expect(screen.queryByText('workflow.tabs.snippets')).not.toBeInTheDocument() expect(screen.getByText('LLM')).toBeInTheDocument() expect(screen.getByText('End')).toBeInTheDocument() diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 6678f081fe9..595426a262b 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -132,7 +132,7 @@ function NodeSelector({ const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection const disableStartTab = flowType === FlowType.snippet - const disableSnippetsTab = true + const disableSnippetsTab = flowType === FlowType.snippet const { activeTab, resetActiveTab, diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 0478b0e12f5..dffbd31a5e1 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -12,11 +12,15 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useReactFlowStore } from 'reactflow' +import { useCreateSnippetFromSelection } from '@/app/components/snippets/hooks/use-create-snippet-from-selection' +import { canCreateAndModifySnippets } from '@/app/components/snippets/utils/permission' import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' +import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks' import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history' import { ShortcutKbd } from './shortcuts/shortcut-kbd' import { useStore, useWorkflowStore } from './store' +import { BlockEnum } from './types' const AlignType = { Bottom: 'bottom', @@ -71,6 +75,14 @@ const menuSections: MenuSection[] = [ }, ] +const unsupportedSnippetNodeTypes = new Set([ + BlockEnum.Answer, + BlockEnum.End, + BlockEnum.Start, + BlockEnum.HumanInput, + BlockEnum.KnowledgeRetrieval, +]) + const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => { const selectedNodeIds = new Set(selectedNodes.map(node => node.id)) const childNodeIds = new Set() @@ -223,6 +235,7 @@ export function SelectionContextmenu({ }) { const { t } = useTranslation() const { getNodesReadOnly } = useNodesReadOnly() + const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions() const isSelectionContextMenu = useStore(s => s.contextMenuTarget?.type === 'selection') @@ -234,8 +247,20 @@ export function SelectionContextmenu({ const selectedNodes = useReactFlowStore(state => state.getNodes().filter(node => node.selected), ) + const edges = useReactFlowStore(state => state.edges) const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { saveStateToHistory } = useWorkflowHistory() + const { + createSnippetDialog, + handleOpenCreateSnippet, + isCreateSnippetDialogOpen, + } = useCreateSnippetFromSelection({ + edges, + selectedNodes, + onClose, + }) + const canCreateSnippet = canCreateAndModifySnippets(workspacePermissionKeys) + && selectedNodes.every(node => !unsupportedSnippetNodeTypes.has(node.data.type)) const handleCopyNodes = useCallback(() => { handleNodesCopy() @@ -345,11 +370,24 @@ export function SelectionContextmenu({ }, [collaborativeWorkflow, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, onClose]) if (!isSelectionContextMenu || selectedNodes.length <= 1) - return null + return isCreateSnippetDialogOpen ? createSnippetDialog : null return ( <> + {canCreateSnippet && ( + <> + + + {t('snippet.createDialogTitle', { defaultValue: 'Create Snippet', ns: 'workflow' })} + + + + + )} ))} + {createSnippetDialog} ) }