mirror of
https://github.com/langgenius/dify.git
synced 2026-06-23 12:31:13 +08:00
feat(web): hide snippets (#37729)
This commit is contained in:
parent
3e606ff0dc
commit
7e9cb50152
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user