feat(web): hide snippets (#37729)

This commit is contained in:
KVOJJJin 2026-06-22 12:51:47 +08:00 committed by GitHub
parent 3e606ff0dc
commit 7e9cb50152
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 8 additions and 205 deletions

View File

@ -421,18 +421,16 @@ describe('List', () => {
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
})
it('should render sort filter before search and the snippets link', () => {
it('should render sort filter before search and hide 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(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(snippetsLink.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(searchInput.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
})
it('should render app cards when apps exist', () => {

View File

@ -8,7 +8,6 @@ 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'
@ -71,12 +70,6 @@ export function AppListHeaderFilters({
/>
</div>
<div className="flex items-center gap-2">
<Link
href="/snippets"
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{t('studio.viewSnippets', { ns: 'app' })}
</Link>
{showCreateButton && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger

View File

@ -3,10 +3,8 @@ import { ContextMenu } from '@langgenius/dify-ui/context-menu'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { useEffect } from 'react'
import { useNodes } from 'reactflow'
import { PipelineInputVarType } from '@/models/pipeline'
import { SelectionContextmenu } from '../selection-contextmenu'
import { useWorkflowStore } from '../store'
import { BlockEnum } from '../types'
import { useWorkflowHistoryStore } from '../workflow-history-store'
import { createEdge, createNode } from './fixtures'
import { renderWorkflowFlowComponent } from './workflow-test-env'
@ -17,48 +15,6 @@ const mockGetNodesReadOnly = vi.fn()
const mockHandleNodesCopy = vi.fn()
const mockHandleNodesDuplicate = vi.fn()
const mockHandleNodesDelete = vi.fn()
const mockHandleCreateSnippet = vi.fn()
const mockCreateSnippetDialogRender = vi.fn()
const mockWorkspacePermissionKeys = vi.hoisted(() => ({
value: ['snippets.create_and_modify'] as string[],
}))
vi.mock('@/context/app-context', () => ({
useSelector: <T,>(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<typeof import('react')>('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 ? <div data-testid="create-snippet-dialog" /> : null
},
}))
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
@ -142,9 +98,6 @@ 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', () => {
@ -203,41 +156,7 @@ describe('SelectionContextmenu', () => {
expect(store.getState().contextMenuTarget).toBeUndefined()
})
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 = []
it('should hide create snippet action for selected nodes', 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 }),
@ -252,76 +171,7 @@ describe('SelectionContextmenu', () => {
expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument()
})
expect(screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })).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()
expect(screen.queryByTestId('create-snippet-dialog')).not.toBeInTheDocument()
})
it('should stay hidden when only one node is selected', async () => {

View File

@ -106,6 +106,7 @@ 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()

View File

@ -132,7 +132,7 @@ function NodeSelector({
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
const disableStartTab = flowType === FlowType.snippet
const disableSnippetsTab = flowType === FlowType.snippet
const disableSnippetsTab = true
const {
activeTab,
resetActiveTab,

View File

@ -12,15 +12,11 @@ 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',
@ -75,14 +71,6 @@ 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<string>()
@ -235,7 +223,6 @@ 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')
@ -247,20 +234,8 @@ 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()
@ -370,24 +345,11 @@ export function SelectionContextmenu({
}, [collaborativeWorkflow, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, onClose])
if (!isSelectionContextMenu || selectedNodes.length <= 1)
return isCreateSnippetDialogOpen ? createSnippetDialog : null
return null
return (
<>
<ContextMenuContent popupClassName="w-[240px]" sideOffset={4}>
{canCreateSnippet && (
<>
<ContextMenuGroup>
<ContextMenuItem
className="px-3 text-text-secondary"
onClick={handleOpenCreateSnippet}
>
<span>{t('snippet.createDialogTitle', { defaultValue: 'Create Snippet', ns: 'workflow' })}</span>
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
</>
)}
<ContextMenuGroup>
<ContextMenuItem
className="justify-between px-3 text-text-secondary"
@ -436,7 +398,6 @@ export function SelectionContextmenu({
</ContextMenuGroup>
))}
</ContextMenuContent>
{createSnippetDialog}
</>
)
}