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}
>
)
}