mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
Merge main HEAD (segment 5) into sandboxed-agent-rebase
Resolve 83 conflicts: 10 backend, 62 frontend, 11 config/lock files. Preserve sandbox/agent/collaboration features while adopting main's UI refactorings (Dialog/AlertDialog/Popover), model provider updates, and enterprise features. Made-with: Cursor
This commit is contained in:
@ -0,0 +1,40 @@
|
||||
import type { Node } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import CandidateNode from '../candidate-node'
|
||||
import { BlockEnum } from '../types'
|
||||
import { renderWorkflowComponent } from './workflow-test-env'
|
||||
|
||||
vi.mock('../candidate-node-main', () => ({
|
||||
default: ({ candidateNode }: { candidateNode: Node }) => (
|
||||
<div data-testid="candidate-node-main">{candidateNode.id}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createCandidateNode = (): Node => ({
|
||||
id: 'candidate-node-1',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: 'Candidate node',
|
||||
desc: 'candidate',
|
||||
},
|
||||
})
|
||||
|
||||
describe('CandidateNode', () => {
|
||||
it('should not render when candidateNode is missing from the workflow store', () => {
|
||||
renderWorkflowComponent(<CandidateNode />)
|
||||
|
||||
expect(screen.queryByTestId('candidate-node-main')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CandidateNodeMain with the stored candidate node', () => {
|
||||
renderWorkflowComponent(<CandidateNode />, {
|
||||
initialStoreState: {
|
||||
candidateNode: createCandidateNode(),
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('candidate-node-main')).toHaveTextContent('candidate-node-1')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,81 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { getBezierPath, Position } from 'reactflow'
|
||||
import CustomConnectionLine from '../custom-connection-line'
|
||||
|
||||
const createConnectionLineProps = (
|
||||
overrides: Partial<ComponentProps<typeof CustomConnectionLine>> = {},
|
||||
): ComponentProps<typeof CustomConnectionLine> => ({
|
||||
fromX: 10,
|
||||
fromY: 20,
|
||||
toX: 70,
|
||||
toY: 80,
|
||||
fromPosition: Position.Right,
|
||||
toPosition: Position.Left,
|
||||
connectionLineType: undefined,
|
||||
connectionStatus: null,
|
||||
...overrides,
|
||||
} as ComponentProps<typeof CustomConnectionLine>)
|
||||
|
||||
describe('CustomConnectionLine', () => {
|
||||
it('should render the bezier path and target marker', () => {
|
||||
const [expectedPath] = getBezierPath({
|
||||
sourceX: 10,
|
||||
sourceY: 20,
|
||||
sourcePosition: Position.Right,
|
||||
targetX: 70,
|
||||
targetY: 80,
|
||||
targetPosition: Position.Left,
|
||||
curvature: 0.16,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<CustomConnectionLine {...createConnectionLineProps()} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
const path = container.querySelector('path')
|
||||
const marker = container.querySelector('rect')
|
||||
|
||||
expect(path).toHaveAttribute('fill', 'none')
|
||||
expect(path).toHaveAttribute('stroke', '#D0D5DD')
|
||||
expect(path).toHaveAttribute('stroke-width', '2')
|
||||
expect(path).toHaveAttribute('d', expectedPath)
|
||||
|
||||
expect(marker).toHaveAttribute('x', '70')
|
||||
expect(marker).toHaveAttribute('y', '76')
|
||||
expect(marker).toHaveAttribute('width', '2')
|
||||
expect(marker).toHaveAttribute('height', '8')
|
||||
expect(marker).toHaveAttribute('fill', '#2970FF')
|
||||
})
|
||||
|
||||
it('should update the path when the endpoints change', () => {
|
||||
const [expectedPath] = getBezierPath({
|
||||
sourceX: 30,
|
||||
sourceY: 40,
|
||||
sourcePosition: Position.Right,
|
||||
targetX: 160,
|
||||
targetY: 200,
|
||||
targetPosition: Position.Left,
|
||||
curvature: 0.16,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<CustomConnectionLine
|
||||
{...createConnectionLineProps({
|
||||
fromX: 30,
|
||||
fromY: 40,
|
||||
toX: 160,
|
||||
toY: 200,
|
||||
})}
|
||||
/>
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('path')).toHaveAttribute('d', expectedPath)
|
||||
expect(container.querySelector('rect')).toHaveAttribute('x', '160')
|
||||
expect(container.querySelector('rect')).toHaveAttribute('y', '196')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,57 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import CustomEdgeLinearGradientRender from '../custom-edge-linear-gradient-render'
|
||||
|
||||
describe('CustomEdgeLinearGradientRender', () => {
|
||||
it('should render gradient definition with the provided id and positions', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<CustomEdgeLinearGradientRender
|
||||
id="edge-gradient"
|
||||
startColor="#123456"
|
||||
stopColor="#abcdef"
|
||||
position={{
|
||||
x1: 10,
|
||||
y1: 20,
|
||||
x2: 30,
|
||||
y2: 40,
|
||||
}}
|
||||
/>
|
||||
</svg>,
|
||||
)
|
||||
|
||||
const gradient = container.querySelector('linearGradient')
|
||||
expect(gradient).toHaveAttribute('id', 'edge-gradient')
|
||||
expect(gradient).toHaveAttribute('gradientUnits', 'userSpaceOnUse')
|
||||
expect(gradient).toHaveAttribute('x1', '10')
|
||||
expect(gradient).toHaveAttribute('y1', '20')
|
||||
expect(gradient).toHaveAttribute('x2', '30')
|
||||
expect(gradient).toHaveAttribute('y2', '40')
|
||||
})
|
||||
|
||||
it('should render start and stop colors at both ends of the gradient', () => {
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<CustomEdgeLinearGradientRender
|
||||
id="gradient-colors"
|
||||
startColor="#111111"
|
||||
stopColor="#222222"
|
||||
position={{
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 100,
|
||||
y2: 100,
|
||||
}}
|
||||
/>
|
||||
</svg>,
|
||||
)
|
||||
|
||||
const stops = container.querySelectorAll('stop')
|
||||
expect(stops).toHaveLength(2)
|
||||
expect(stops[0]).toHaveAttribute('offset', '0%')
|
||||
expect(stops[0].getAttribute('style')).toContain('stop-color: rgb(17, 17, 17)')
|
||||
expect(stops[0].getAttribute('style')).toContain('stop-opacity: 1')
|
||||
expect(stops[1]).toHaveAttribute('offset', '100%')
|
||||
expect(stops[1].getAttribute('style')).toContain('stop-color: rgb(34, 34, 34)')
|
||||
expect(stops[1].getAttribute('style')).toContain('stop-opacity: 1')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,127 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import DSLExportConfirmModal from '../dsl-export-confirm-modal'
|
||||
|
||||
const envList = [
|
||||
{
|
||||
id: 'env-1',
|
||||
name: 'SECRET_TOKEN',
|
||||
value: 'masked-value',
|
||||
value_type: 'secret' as const,
|
||||
description: 'secret token',
|
||||
},
|
||||
]
|
||||
|
||||
const multiEnvList = [
|
||||
...envList,
|
||||
{
|
||||
id: 'env-2',
|
||||
name: 'SERVICE_KEY',
|
||||
value: 'another-secret',
|
||||
value_type: 'secret' as const,
|
||||
description: 'service key',
|
||||
},
|
||||
]
|
||||
|
||||
describe('DSLExportConfirmModal', () => {
|
||||
it('should render environment rows and close when cancel is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={envList}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('SECRET_TOKEN')).toBeInTheDocument()
|
||||
expect(screen.getByText('masked-value')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should confirm with exportSecrets=false by default', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={envList}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.env.export.ignore' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(false)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should confirm with exportSecrets=true after toggling the checkbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={envList}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('checkbox'))
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should also toggle exportSecrets when the label text is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={envList}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('workflow.env.export.checkbox'))
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render border separators for all rows except the last one', () => {
|
||||
render(
|
||||
<DSLExportConfirmModal
|
||||
envList={multiEnvList}
|
||||
onConfirm={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const firstNameCell = screen.getByText('SECRET_TOKEN').closest('td')
|
||||
const lastNameCell = screen.getByText('SERVICE_KEY').closest('td')
|
||||
const firstValueCell = screen.getByText('masked-value').closest('td')
|
||||
const lastValueCell = screen.getByText('another-secret').closest('td')
|
||||
|
||||
expect(firstNameCell).toHaveClass('border-b')
|
||||
expect(firstValueCell).toHaveClass('border-b')
|
||||
expect(lastNameCell).not.toHaveClass('border-b')
|
||||
expect(lastValueCell).not.toHaveClass('border-b')
|
||||
})
|
||||
})
|
||||
410
web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx
Normal file
410
web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx
Normal file
@ -0,0 +1,410 @@
|
||||
import type { Edge, Node } from '../types'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect } from 'react'
|
||||
import { useEdges, useNodes, useStoreApi } from 'reactflow'
|
||||
import { createEdge, createNode } from '../__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '../__tests__/workflow-test-env'
|
||||
import EdgeContextmenu from '../edge-contextmenu'
|
||||
import { useEdgesInteractions } from '../hooks/use-edges-interactions'
|
||||
|
||||
const mockSaveStateToHistory = vi.fn()
|
||||
|
||||
vi.mock('../hooks/use-workflow-history', () => ({
|
||||
useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory }),
|
||||
WorkflowHistoryEvent: {
|
||||
EdgeDelete: 'EdgeDelete',
|
||||
EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch',
|
||||
EdgeSourceHandleChange: 'EdgeSourceHandleChange',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-workflow', () => ({
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: () => false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../utils')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getNodesConnectedSourceOrTargetHandleIdsMap: vi.fn(() => ({})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../hooks', async () => {
|
||||
const { useEdgesInteractions } = await import('../hooks/use-edges-interactions')
|
||||
const { usePanelInteractions } = await import('../hooks/use-panel-interactions')
|
||||
|
||||
return {
|
||||
useEdgesInteractions,
|
||||
usePanelInteractions,
|
||||
}
|
||||
})
|
||||
|
||||
type EdgeRuntimeState = {
|
||||
_hovering?: boolean
|
||||
_isBundled?: boolean
|
||||
}
|
||||
|
||||
type NodeRuntimeState = {
|
||||
selected?: boolean
|
||||
_isBundled?: boolean
|
||||
}
|
||||
|
||||
const getEdgeRuntimeState = (edge?: Edge): EdgeRuntimeState =>
|
||||
(edge?.data ?? {}) as EdgeRuntimeState
|
||||
|
||||
const getNodeRuntimeState = (node?: Node): NodeRuntimeState =>
|
||||
(node?.data ?? {}) as NodeRuntimeState
|
||||
|
||||
function createFlowNodes() {
|
||||
return [
|
||||
createNode({ id: 'n1' }),
|
||||
createNode({ id: 'n2', position: { x: 100, y: 0 } }),
|
||||
]
|
||||
}
|
||||
|
||||
function createFlowEdges() {
|
||||
return [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-a',
|
||||
data: { _hovering: false },
|
||||
selected: true,
|
||||
}),
|
||||
createEdge({
|
||||
id: 'e2',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-b',
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
let latestNodes: Node[] = []
|
||||
let latestEdges: Edge[] = []
|
||||
|
||||
const RuntimeProbe = () => {
|
||||
latestNodes = useNodes() as Node[]
|
||||
latestEdges = useEdges() as Edge[]
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const hooksStoreProps = {
|
||||
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const EdgeMenuHarness = () => {
|
||||
const { handleEdgeContextMenu, handleEdgeDelete } = useEdgesInteractions()
|
||||
const edges = useEdges() as Edge[]
|
||||
const reactFlowStore = useStoreApi()
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace')
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
handleEdgeDelete()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleEdgeDelete])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RuntimeProbe />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Right-click edge e1"
|
||||
onContextMenu={e => handleEdgeContextMenu(e as never, edges.find(edge => edge.id === 'e1') as never)}
|
||||
>
|
||||
edge-e1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Right-click edge e2"
|
||||
onContextMenu={e => handleEdgeContextMenu(e as never, edges.find(edge => edge.id === 'e2') as never)}
|
||||
>
|
||||
edge-e2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove edge e1"
|
||||
onClick={() => {
|
||||
const { edges, setEdges } = reactFlowStore.getState()
|
||||
setEdges(edges.filter(edge => edge.id !== 'e1'))
|
||||
}}
|
||||
>
|
||||
remove-e1
|
||||
</button>
|
||||
<EdgeContextmenu />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderEdgeMenu(options?: {
|
||||
nodes?: Node[]
|
||||
edges?: Edge[]
|
||||
initialStoreState?: Record<string, unknown>
|
||||
}) {
|
||||
const { nodes = createFlowNodes(), edges = createFlowEdges(), initialStoreState } = options ?? {}
|
||||
|
||||
return renderWorkflowFlowComponent(<EdgeMenuHarness />, {
|
||||
nodes,
|
||||
edges,
|
||||
initialStoreState,
|
||||
hooksStoreProps,
|
||||
reactFlowProps: { fitView: false },
|
||||
})
|
||||
}
|
||||
|
||||
describe('EdgeContextmenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestNodes = []
|
||||
latestEdges = []
|
||||
})
|
||||
|
||||
it('should not render when edgeMenu is absent', () => {
|
||||
renderWorkflowFlowComponent(<EdgeContextmenu />, {
|
||||
nodes: createFlowNodes(),
|
||||
edges: createFlowEdges(),
|
||||
hooksStoreProps,
|
||||
reactFlowProps: { fitView: false },
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should delete the menu edge and close the menu when another edge is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderEdgeMenu({
|
||||
edges: [
|
||||
createEdge({
|
||||
id: 'e1',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-a',
|
||||
selected: true,
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
createEdge({
|
||||
id: 'e2',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
sourceHandle: 'branch-b',
|
||||
selected: false,
|
||||
data: { _hovering: false },
|
||||
}),
|
||||
],
|
||||
initialStoreState: {
|
||||
edgeMenu: {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
edgeId: 'e2',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i })
|
||||
expect(screen.getByText(/^del$/i)).toBeInTheDocument()
|
||||
|
||||
await user.click(deleteAction)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(latestEdges).toHaveLength(1)
|
||||
expect(latestEdges[0].id).toBe('e1')
|
||||
expect(latestEdges[0].selected).toBe(true)
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
|
||||
})
|
||||
|
||||
it('should not render the menu when the referenced edge no longer exists', () => {
|
||||
renderWorkflowFlowComponent(<EdgeContextmenu />, {
|
||||
nodes: createFlowNodes(),
|
||||
edges: createFlowEdges(),
|
||||
initialStoreState: {
|
||||
edgeMenu: {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
edgeId: 'missing-edge',
|
||||
},
|
||||
},
|
||||
hooksStoreProps,
|
||||
reactFlowProps: { fitView: false },
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the edge menu at the right-click position', async () => {
|
||||
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
|
||||
|
||||
renderEdgeMenu()
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: /common:operation\.delete/i })).toBeInTheDocument()
|
||||
expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
x: 320,
|
||||
y: 180,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should delete the right-clicked edge and close the menu when delete is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderEdgeMenu()
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
})
|
||||
|
||||
await user.click(await screen.findByRole('menuitem', { name: /common:operation\.delete/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(latestEdges.map(edge => edge.id)).toEqual(['e1'])
|
||||
})
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Delete', 'Delete'],
|
||||
['Backspace', 'Backspace'],
|
||||
])('should delete the right-clicked edge with %s after switching from a selected node', async (_, key) => {
|
||||
renderEdgeMenu({
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
selected: true,
|
||||
data: { selected: true, _isBundled: true },
|
||||
}),
|
||||
createNode({
|
||||
id: 'n2',
|
||||
position: { x: 100, y: 0 },
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
|
||||
clientX: 240,
|
||||
clientY: 120,
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document.body, { key })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(latestEdges.map(edge => edge.id)).toEqual(['e1'])
|
||||
expect(latestNodes.map(node => node.id)).toEqual(['n1', 'n2'])
|
||||
expect(latestNodes.every(node => !node.selected && !getNodeRuntimeState(node).selected)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep bundled multi-selection nodes intact when delete runs after right-clicking an edge', async () => {
|
||||
renderEdgeMenu({
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'n1',
|
||||
selected: true,
|
||||
data: { selected: true, _isBundled: true },
|
||||
}),
|
||||
createNode({
|
||||
id: 'n2',
|
||||
position: { x: 100, y: 0 },
|
||||
selected: true,
|
||||
data: { selected: true, _isBundled: true },
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
|
||||
clientX: 200,
|
||||
clientY: 100,
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'Delete' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(latestEdges.map(edge => edge.id)).toEqual(['e2'])
|
||||
expect(latestNodes).toHaveLength(2)
|
||||
expect(latestNodes.every(node =>
|
||||
!node.selected
|
||||
&& !getNodeRuntimeState(node).selected
|
||||
&& !getNodeRuntimeState(node)._isBundled,
|
||||
)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should retarget the menu and selected edge when right-clicking a different edge', async () => {
|
||||
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
|
||||
|
||||
renderEdgeMenu()
|
||||
const edgeOneButton = screen.getByLabelText('Right-click edge e1')
|
||||
const edgeTwoButton = screen.getByLabelText('Right-click edge e2')
|
||||
|
||||
fireEvent.contextMenu(edgeOneButton, {
|
||||
clientX: 80,
|
||||
clientY: 60,
|
||||
})
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
fireEvent.contextMenu(edgeTwoButton, {
|
||||
clientX: 360,
|
||||
clientY: 240,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('menu')).toHaveLength(1)
|
||||
expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
x: 360,
|
||||
y: 240,
|
||||
}))
|
||||
expect(latestEdges.find(edge => edge.id === 'e1')?.selected).toBe(false)
|
||||
expect(latestEdges.find(edge => edge.id === 'e2')?.selected).toBe(true)
|
||||
expect(latestEdges.every(edge => !getEdgeRuntimeState(edge)._isBundled)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide the menu when the target edge disappears after opening it', async () => {
|
||||
const { container } = renderEdgeMenu()
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
|
||||
clientX: 160,
|
||||
clientY: 100,
|
||||
})
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(container.querySelector('button[aria-label="Remove edge e1"]') as HTMLButtonElement)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
191
web/app/components/workflow/__tests__/features.spec.tsx
Normal file
191
web/app/components/workflow/__tests__/features.spec.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import type { InputVar } from '../types'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useNodes } from 'reactflow'
|
||||
import Features from '../features'
|
||||
import { InputVarType } from '../types'
|
||||
import { createStartNode } from './fixtures'
|
||||
import { renderWorkflowFlowComponent } from './workflow-test-env'
|
||||
|
||||
const mockHandleSyncWorkflowDraft = vi.fn()
|
||||
const mockHandleAddVariable = vi.fn()
|
||||
|
||||
let mockIsChatMode = true
|
||||
let mockNodesReadOnly = false
|
||||
|
||||
vi.mock('../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useIsChatMode: () => mockIsChatMode,
|
||||
useNodesReadOnly: () => ({
|
||||
nodesReadOnly: mockNodesReadOnly,
|
||||
}),
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../nodes/start/use-config', () => ({
|
||||
default: () => ({
|
||||
handleAddVariable: mockHandleAddVariable,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel', () => ({
|
||||
default: ({
|
||||
show,
|
||||
isChatMode,
|
||||
disabled,
|
||||
onChange,
|
||||
onClose,
|
||||
onAutoAddPromptVariable,
|
||||
workflowVariables,
|
||||
}: {
|
||||
show: boolean
|
||||
isChatMode: boolean
|
||||
disabled: boolean
|
||||
onChange: () => void
|
||||
onClose: () => void
|
||||
onAutoAddPromptVariable: (variables: PromptVariable[]) => void
|
||||
workflowVariables: InputVar[]
|
||||
}) => {
|
||||
if (!show)
|
||||
return null
|
||||
|
||||
return (
|
||||
<section aria-label="new feature panel">
|
||||
<div>{isChatMode ? 'chat mode' : 'completion mode'}</div>
|
||||
<div>{disabled ? 'panel disabled' : 'panel enabled'}</div>
|
||||
<ul aria-label="workflow variables">
|
||||
{workflowVariables.map(variable => (
|
||||
<li key={variable.variable}>
|
||||
{`${variable.label}:${variable.variable}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" onClick={onChange}>open features</button>
|
||||
<button type="button" onClick={onClose}>close features</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAutoAddPromptVariable([{
|
||||
key: 'opening_statement',
|
||||
name: 'Opening Statement',
|
||||
type: 'string',
|
||||
max_length: 200,
|
||||
required: true,
|
||||
}])}
|
||||
>
|
||||
add required variable
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAutoAddPromptVariable([{
|
||||
key: 'optional_statement',
|
||||
name: 'Optional Statement',
|
||||
type: 'string',
|
||||
max_length: 120,
|
||||
}])}
|
||||
>
|
||||
add optional variable
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const startNode = createStartNode({
|
||||
id: 'start-node',
|
||||
data: {
|
||||
variables: [{ variable: 'existing_variable', label: 'Existing Variable' }],
|
||||
},
|
||||
})
|
||||
|
||||
const DelayedFeatures = () => {
|
||||
const nodes = useNodes()
|
||||
|
||||
if (!nodes.length)
|
||||
return null
|
||||
|
||||
return <Features />
|
||||
}
|
||||
|
||||
const renderFeatures = (options?: Omit<Parameters<typeof renderWorkflowFlowComponent>[1], 'nodes' | 'edges'>) =>
|
||||
renderWorkflowFlowComponent(
|
||||
<DelayedFeatures />,
|
||||
{
|
||||
nodes: [startNode],
|
||||
edges: [],
|
||||
...options,
|
||||
},
|
||||
)
|
||||
|
||||
describe('Features', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsChatMode = true
|
||||
mockNodesReadOnly = false
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should pass workflow context to the feature panel', () => {
|
||||
renderFeatures()
|
||||
|
||||
expect(screen.getByText('chat mode')).toBeInTheDocument()
|
||||
expect(screen.getByText('panel enabled')).toBeInTheDocument()
|
||||
expect(screen.getByRole('list', { name: 'workflow variables' })).toHaveTextContent('Existing Variable:existing_variable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should sync the draft and open the workflow feature panel when users change features', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderFeatures()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'open features' }))
|
||||
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().showFeaturesPanel).toBe(true)
|
||||
})
|
||||
|
||||
it('should close the workflow feature panel and transform required prompt variables', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderFeatures({
|
||||
initialStoreState: {
|
||||
showFeaturesPanel: true,
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'close features' }))
|
||||
expect(store.getState().showFeaturesPanel).toBe(false)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'add required variable' }))
|
||||
expect(mockHandleAddVariable).toHaveBeenCalledWith({
|
||||
variable: 'opening_statement',
|
||||
label: 'Opening Statement',
|
||||
type: InputVarType.textInput,
|
||||
max_length: 200,
|
||||
required: true,
|
||||
options: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('should default prompt variables to optional when required is omitted', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderFeatures()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'add optional variable' }))
|
||||
expect(mockHandleAddVariable).toHaveBeenCalledWith({
|
||||
variable: 'optional_statement',
|
||||
label: 'Optional Statement',
|
||||
type: InputVarType.textInput,
|
||||
max_length: 120,
|
||||
required: false,
|
||||
options: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -42,6 +42,13 @@ export function createStartNode(overrides: Omit<Partial<Node>, 'data'> & { data?
|
||||
})
|
||||
}
|
||||
|
||||
export function createNodeDataFactory<T extends CommonNodeType & Record<string, unknown>>(defaults: T) {
|
||||
return (overrides: Partial<T> = {}): T => ({
|
||||
...defaults,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
export function createTriggerNode(
|
||||
triggerType: BlockEnum.TriggerSchedule | BlockEnum.TriggerWebhook | BlockEnum.TriggerPlugin = BlockEnum.TriggerWebhook,
|
||||
overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {},
|
||||
|
||||
9
web/app/components/workflow/__tests__/i18n.ts
Normal file
9
web/app/components/workflow/__tests__/i18n.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
export function resolveDocLink(path: string, baseUrl = 'https://docs.example.com') {
|
||||
return `${baseUrl}${path}`
|
||||
}
|
||||
|
||||
export function createDocLinkMock(baseUrl = 'https://docs.example.com') {
|
||||
return vi.fn((path: string) => resolveDocLink(path, baseUrl))
|
||||
}
|
||||
@ -0,0 +1,179 @@
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
PreferredProviderTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
createCredentialState,
|
||||
createDefaultModel,
|
||||
createModel,
|
||||
createModelItem,
|
||||
createProviderMeta,
|
||||
} from './model-provider-fixtures'
|
||||
|
||||
describe('model-provider-fixtures', () => {
|
||||
describe('createModelItem', () => {
|
||||
it('should return the default text embedding model item', () => {
|
||||
expect(createModelItem()).toEqual({
|
||||
model: 'text-embedding-3-large',
|
||||
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow overriding the default model item fields', () => {
|
||||
expect(createModelItem({
|
||||
model: 'bge-large',
|
||||
status: ModelStatusEnum.disabled,
|
||||
load_balancing_enabled: true,
|
||||
})).toEqual(expect.objectContaining({
|
||||
model: 'bge-large',
|
||||
status: ModelStatusEnum.disabled,
|
||||
load_balancing_enabled: true,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('createModel', () => {
|
||||
it('should build an active provider model with one default model item', () => {
|
||||
const result = createModel()
|
||||
|
||||
expect(result.provider).toBe('openai')
|
||||
expect(result.status).toBe(ModelStatusEnum.active)
|
||||
expect(result.models).toHaveLength(1)
|
||||
expect(result.models[0]).toEqual(createModelItem())
|
||||
})
|
||||
|
||||
it('should use override values for provider metadata and model list', () => {
|
||||
const customModelItem = createModelItem({
|
||||
model: 'rerank-v1',
|
||||
model_type: ModelTypeEnum.rerank,
|
||||
})
|
||||
|
||||
expect(createModel({
|
||||
provider: 'cohere',
|
||||
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
|
||||
models: [customModelItem],
|
||||
})).toEqual(expect.objectContaining({
|
||||
provider: 'cohere',
|
||||
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
|
||||
models: [customModelItem],
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDefaultModel', () => {
|
||||
it('should return the default provider and model selection', () => {
|
||||
expect(createDefaultModel()).toEqual({
|
||||
provider: 'openai',
|
||||
model: 'text-embedding-3-large',
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow overriding the default provider selection', () => {
|
||||
expect(createDefaultModel({
|
||||
provider: 'azure_openai',
|
||||
model: 'text-embedding-3-small',
|
||||
})).toEqual({
|
||||
provider: 'azure_openai',
|
||||
model: 'text-embedding-3-small',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProviderMeta', () => {
|
||||
it('should return provider metadata with credential and system configuration defaults', () => {
|
||||
expect(createProviderMeta()).toEqual({
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
help: {
|
||||
title: { en_US: 'Help', zh_Hans: 'Help' },
|
||||
url: { en_US: 'https://example.com/help', zh_Hans: 'https://example.com/help' },
|
||||
},
|
||||
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
|
||||
icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' },
|
||||
supported_model_types: [ModelTypeEnum.textEmbedding],
|
||||
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||
provider_credential_schema: {
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
model_credential_schema: {
|
||||
model: {
|
||||
label: { en_US: 'Model', zh_Hans: 'Model' },
|
||||
placeholder: { en_US: 'Select model', zh_Hans: 'Select model' },
|
||||
},
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
},
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply provider metadata overrides', () => {
|
||||
expect(createProviderMeta({
|
||||
provider: 'bedrock',
|
||||
supported_model_types: [ModelTypeEnum.textGeneration],
|
||||
preferred_provider_type: PreferredProviderTypeEnum.system,
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
|
||||
quota_configurations: [],
|
||||
},
|
||||
})).toEqual(expect.objectContaining({
|
||||
provider: 'bedrock',
|
||||
supported_model_types: [ModelTypeEnum.textGeneration],
|
||||
preferred_provider_type: PreferredProviderTypeEnum.system,
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
|
||||
quota_configurations: [],
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCredentialState', () => {
|
||||
it('should return the default active credential panel state', () => {
|
||||
expect(createCredentialState()).toEqual({
|
||||
variant: 'api-active',
|
||||
priority: 'apiKeyOnly',
|
||||
supportsCredits: false,
|
||||
showPrioritySwitcher: false,
|
||||
isCreditsExhausted: false,
|
||||
hasCredentials: true,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow overriding the credential panel state', () => {
|
||||
expect(createCredentialState({
|
||||
variant: 'credits-active',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: true,
|
||||
credits: 12,
|
||||
credentialName: 'Primary Key',
|
||||
})).toEqual(expect.objectContaining({
|
||||
variant: 'credits-active',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: true,
|
||||
credits: 12,
|
||||
credentialName: 'Primary Key',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,97 @@
|
||||
import type {
|
||||
DefaultModel,
|
||||
Model,
|
||||
ModelItem,
|
||||
ModelProvider,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { CredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
PreferredProviderTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
export function createModelItem(overrides: Partial<ModelItem> = {}): ModelItem {
|
||||
return {
|
||||
model: 'text-embedding-3-large',
|
||||
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createModel(overrides: Partial<Model> = {}): Model {
|
||||
return {
|
||||
provider: 'openai',
|
||||
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
|
||||
icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' },
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [createModelItem()],
|
||||
status: ModelStatusEnum.active,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultModel(overrides: Partial<DefaultModel> = {}): DefaultModel {
|
||||
return {
|
||||
provider: 'openai',
|
||||
model: 'text-embedding-3-large',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createProviderMeta(overrides: Partial<ModelProvider> = {}): ModelProvider {
|
||||
return {
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
help: {
|
||||
title: { en_US: 'Help', zh_Hans: 'Help' },
|
||||
url: { en_US: 'https://example.com/help', zh_Hans: 'https://example.com/help' },
|
||||
},
|
||||
icon_small: { en_US: 'icon', zh_Hans: 'icon' },
|
||||
icon_small_dark: { en_US: 'icon-dark', zh_Hans: 'icon-dark' },
|
||||
supported_model_types: [ModelTypeEnum.textEmbedding],
|
||||
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||
provider_credential_schema: {
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
model_credential_schema: {
|
||||
model: {
|
||||
label: { en_US: 'Model', zh_Hans: 'Model' },
|
||||
placeholder: { en_US: 'Select model', zh_Hans: 'Select model' },
|
||||
},
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
},
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [],
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createCredentialState(overrides: Partial<CredentialPanelState> = {}): CredentialPanelState {
|
||||
return {
|
||||
variant: 'api-active',
|
||||
priority: 'apiKeyOnly',
|
||||
supportsCredits: false,
|
||||
showPrioritySwitcher: false,
|
||||
isCreditsExhausted: false,
|
||||
hasCredentials: true,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@ -16,8 +16,8 @@ import * as React from 'react'
|
||||
type MockNode = {
|
||||
id: string
|
||||
position: { x: number, y: number }
|
||||
width?: number
|
||||
height?: number
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
parentId?: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
import SyncingDataModal from '../syncing-data-modal'
|
||||
import { renderWorkflowComponent } from './workflow-test-env'
|
||||
|
||||
describe('SyncingDataModal', () => {
|
||||
it('should not render when workflow draft syncing is disabled', () => {
|
||||
const { container } = renderWorkflowComponent(<SyncingDataModal />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render the fullscreen overlay when workflow draft syncing is enabled', () => {
|
||||
const { container } = renderWorkflowComponent(<SyncingDataModal />, {
|
||||
initialStoreState: {
|
||||
isSyncingWorkflowDraft: true,
|
||||
},
|
||||
})
|
||||
|
||||
const overlay = container.firstElementChild
|
||||
expect(overlay).toHaveClass('absolute', 'inset-0')
|
||||
expect(overlay).toHaveClass('z-[9999]')
|
||||
})
|
||||
})
|
||||
@ -1,16 +1,12 @@
|
||||
import type { EdgeChange, ReactFlowProps } from 'reactflow'
|
||||
import type { Edge, Node } from '../types'
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { BaseEdge, internalsSymbol, Position, ReactFlowProvider, useStoreApi } from 'reactflow'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { WORKFLOW_DATA_UPDATE } from '../constants'
|
||||
import { Workflow } from '../index'
|
||||
import { renderWorkflowComponent } from './workflow-test-env'
|
||||
|
||||
const reactFlowState = vi.hoisted(() => ({
|
||||
lastProps: null as ReactFlowProps | null,
|
||||
}))
|
||||
|
||||
type WorkflowUpdateEvent = {
|
||||
type: string
|
||||
payload: {
|
||||
@ -23,6 +19,10 @@ const eventEmitterState = vi.hoisted(() => ({
|
||||
subscription: null as null | ((payload: WorkflowUpdateEvent) => void),
|
||||
}))
|
||||
|
||||
const reactFlowBridge = vi.hoisted(() => ({
|
||||
store: null as null | ReturnType<typeof useStoreApi>,
|
||||
}))
|
||||
|
||||
const workflowHookMocks = vi.hoisted(() => ({
|
||||
handleNodeDragStart: vi.fn(),
|
||||
handleNodeDrag: vi.fn(),
|
||||
@ -52,97 +52,64 @@ const workflowHookMocks = vi.hoisted(() => ({
|
||||
useWorkflowSearch: vi.fn(),
|
||||
}))
|
||||
|
||||
function createInitializedNode(id: string, x: number, label: string) {
|
||||
return {
|
||||
id,
|
||||
position: { x, y: 0 },
|
||||
positionAbsolute: { x, y: 0 },
|
||||
width: 160,
|
||||
height: 40,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
data: { label },
|
||||
[internalsSymbol]: {
|
||||
positionAbsolute: { x, y: 0 },
|
||||
handleBounds: {
|
||||
source: [{
|
||||
id: null,
|
||||
nodeId: id,
|
||||
type: 'source',
|
||||
position: Position.Right,
|
||||
x: 160,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 40,
|
||||
}],
|
||||
target: [{
|
||||
id: null,
|
||||
nodeId: id,
|
||||
type: 'target',
|
||||
position: Position.Left,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 40,
|
||||
}],
|
||||
},
|
||||
z: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const baseNodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
},
|
||||
createInitializedNode('node-1', 0, 'Workflow node node-1'),
|
||||
createInitializedNode('node-2', 240, 'Workflow node node-2'),
|
||||
] as unknown as Node[]
|
||||
|
||||
const baseEdges = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
type: 'custom',
|
||||
source: 'node-1',
|
||||
target: 'node-2',
|
||||
data: { sourceType: 'start', targetType: 'end' },
|
||||
},
|
||||
] as unknown as Edge[]
|
||||
|
||||
const edgeChanges: EdgeChange[] = [{ id: 'edge-1', type: 'remove' }]
|
||||
|
||||
function createMouseEvent() {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
} as unknown as React.MouseEvent<Element, MouseEvent>
|
||||
}
|
||||
|
||||
vi.mock('next/dynamic', () => ({
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: () => () => null,
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ appId: 'test-app-id' }),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
usePathname: () => '/app/test-app-id',
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const mod = await import('./reactflow-mock-state')
|
||||
const base = mod.createReactFlowModuleMock()
|
||||
const ReactFlowMock = (props: ReactFlowProps) => {
|
||||
reactFlowState.lastProps = props
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'reactflow-mock' },
|
||||
React.createElement('button', {
|
||||
'type': 'button',
|
||||
'aria-label': 'Emit edge mouse enter',
|
||||
'onClick': () => props.onEdgeMouseEnter?.(createMouseEvent(), baseEdges[0]),
|
||||
}),
|
||||
React.createElement('button', {
|
||||
'type': 'button',
|
||||
'aria-label': 'Emit edge mouse leave',
|
||||
'onClick': () => props.onEdgeMouseLeave?.(createMouseEvent(), baseEdges[0]),
|
||||
}),
|
||||
React.createElement('button', {
|
||||
'type': 'button',
|
||||
'aria-label': 'Emit edges change',
|
||||
'onClick': () => props.onEdgesChange?.(edgeChanges),
|
||||
}),
|
||||
React.createElement('button', {
|
||||
'type': 'button',
|
||||
'aria-label': 'Emit edge context menu',
|
||||
'onClick': () => props.onEdgeContextMenu?.(createMouseEvent(), baseEdges[0]),
|
||||
}),
|
||||
React.createElement('button', {
|
||||
'type': 'button',
|
||||
'aria-label': 'Emit node context menu',
|
||||
'onClick': () => props.onNodeContextMenu?.(createMouseEvent(), baseNodes[0]),
|
||||
}),
|
||||
React.createElement('button', {
|
||||
'type': 'button',
|
||||
'aria-label': 'Emit pane context menu',
|
||||
'onClick': () => props.onPaneContextMenu?.(createMouseEvent()),
|
||||
}),
|
||||
props.children,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
SelectionMode: {
|
||||
Partial: 'partial',
|
||||
},
|
||||
ReactFlow: ReactFlowMock,
|
||||
default: ReactFlowMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
@ -173,7 +140,10 @@ vi.mock('../custom-connection-line', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../custom-edge', () => ({
|
||||
default: () => null,
|
||||
default: () => React.createElement(BaseEdge, {
|
||||
id: 'edge-1',
|
||||
path: 'M 0 0 L 100 0',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../help-line', () => ({
|
||||
@ -189,7 +159,7 @@ vi.mock('../node-contextmenu', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../nodes', () => ({
|
||||
default: () => null,
|
||||
default: ({ id }: { id: string }) => React.createElement('div', { 'data-testid': `workflow-node-${id}` }, `Workflow node ${id}`),
|
||||
}))
|
||||
|
||||
vi.mock('../nodes/data-source-empty', () => ({
|
||||
@ -297,17 +267,24 @@ vi.mock('../nodes/_base/components/variable/use-match-schema-type', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../workflow-history-store', () => ({
|
||||
WorkflowHistoryProvider: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
}))
|
||||
function renderSubject(options?: {
|
||||
nodes?: Node[]
|
||||
edges?: Edge[]
|
||||
initialStoreState?: Record<string, unknown>
|
||||
}) {
|
||||
const { nodes = baseNodes, edges = baseEdges, initialStoreState } = options ?? {}
|
||||
|
||||
function renderSubject() {
|
||||
return renderWorkflowComponent(
|
||||
<Workflow
|
||||
nodes={baseNodes}
|
||||
edges={baseEdges}
|
||||
/>,
|
||||
<ReactFlowProvider>
|
||||
<Workflow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
>
|
||||
<ReactFlowEdgeBootstrap nodes={nodes} edges={edges} />
|
||||
</Workflow>
|
||||
</ReactFlowProvider>,
|
||||
{
|
||||
initialStoreState,
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
@ -319,75 +296,106 @@ function renderSubject() {
|
||||
)
|
||||
}
|
||||
|
||||
function ReactFlowEdgeBootstrap({ nodes, edges }: { nodes: Node[], edges: Edge[] }) {
|
||||
const store = useStoreApi()
|
||||
|
||||
React.useEffect(() => {
|
||||
store.setState({
|
||||
edges,
|
||||
width: 500,
|
||||
height: 500,
|
||||
nodeInternals: new Map(nodes.map(node => [node.id, node])),
|
||||
})
|
||||
reactFlowBridge.store = store
|
||||
|
||||
return () => {
|
||||
reactFlowBridge.store = null
|
||||
}
|
||||
}, [edges, nodes, store])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getPane(container: HTMLElement) {
|
||||
const pane = container.querySelector('.react-flow__pane') as HTMLElement | null
|
||||
|
||||
if (!pane)
|
||||
throw new Error('Expected a rendered React Flow pane')
|
||||
|
||||
return pane
|
||||
}
|
||||
|
||||
describe('Workflow edge event wiring', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reactFlowState.lastProps = null
|
||||
eventEmitterState.subscription = null
|
||||
reactFlowBridge.store = null
|
||||
})
|
||||
|
||||
it('should forward React Flow edge events to workflow handlers when emitted by the canvas', () => {
|
||||
renderSubject()
|
||||
it('should forward pane, node and edge-change events to workflow handlers when emitted by the canvas', async () => {
|
||||
const { container } = renderSubject()
|
||||
const pane = getPane(container)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Emit edge mouse enter' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Emit edge mouse leave' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Emit edges change' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Emit edge context menu' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Emit node context menu' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Emit pane context menu' }))
|
||||
act(() => {
|
||||
fireEvent.contextMenu(screen.getByText('Workflow node node-1'), { clientX: 24, clientY: 48 })
|
||||
fireEvent.contextMenu(pane, { clientX: 24, clientY: 48 })
|
||||
})
|
||||
|
||||
expect(workflowHookMocks.handleEdgeEnter).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}), baseEdges[0])
|
||||
expect(workflowHookMocks.handleEdgeLeave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}), baseEdges[0])
|
||||
expect(workflowHookMocks.handleEdgesChange).toHaveBeenCalledWith(edgeChanges)
|
||||
expect(workflowHookMocks.handleEdgeContextMenu).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}), baseEdges[0])
|
||||
expect(workflowHookMocks.handleNodeContextMenu).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}), baseNodes[0])
|
||||
expect(workflowHookMocks.handlePaneContextMenu).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}))
|
||||
await waitFor(() => {
|
||||
expect(reactFlowBridge.store?.getState().onEdgesChange).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
reactFlowBridge.store?.getState().onEdgesChange?.([{ id: 'edge-1', type: 'select', selected: true }])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(workflowHookMocks.handleEdgesChange).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'edge-1', type: 'select' }),
|
||||
]))
|
||||
expect(workflowHookMocks.handleNodeContextMenu).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}), expect.objectContaining({ id: 'node-1' }))
|
||||
expect(workflowHookMocks.handlePaneContextMenu).toHaveBeenCalledWith(expect.objectContaining({
|
||||
clientX: 24,
|
||||
clientY: 48,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep edge deletion delegated to workflow shortcuts instead of React Flow defaults', () => {
|
||||
renderSubject()
|
||||
it('should keep edge deletion delegated to workflow shortcuts instead of React Flow defaults', async () => {
|
||||
renderSubject({
|
||||
edges: [
|
||||
{
|
||||
...baseEdges[0],
|
||||
selected: true,
|
||||
} as Edge,
|
||||
],
|
||||
})
|
||||
|
||||
expect(reactFlowState.lastProps?.deleteKeyCode).toBeNull()
|
||||
act(() => {
|
||||
fireEvent.keyDown(document.body, { key: 'Delete' })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Workflow node node-1')).toBeInTheDocument()
|
||||
})
|
||||
expect(workflowHookMocks.handleEdgesChange).not.toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'edge-1', type: 'remove' }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('should clear edgeMenu when workflow data updates remove the current edge', () => {
|
||||
const { store } = renderWorkflowComponent(
|
||||
<Workflow
|
||||
nodes={baseNodes}
|
||||
edges={baseEdges}
|
||||
/>,
|
||||
{
|
||||
initialStoreState: {
|
||||
edgeMenu: {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
edgeId: 'edge-1',
|
||||
},
|
||||
},
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {},
|
||||
},
|
||||
const { store } = renderSubject({
|
||||
initialStoreState: {
|
||||
edgeMenu: {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
edgeId: 'edge-1',
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
eventEmitterState.subscription?.({
|
||||
|
||||
@ -4,10 +4,17 @@
|
||||
import type { Shape } from '../store/workflow'
|
||||
import { act, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { useHooksStore } from '../hooks-store/store'
|
||||
import { useStore, useWorkflowStore } from '../store/workflow'
|
||||
import { renderNodeComponent, renderWorkflowComponent } from './workflow-test-env'
|
||||
import { createNode } from './fixtures'
|
||||
import {
|
||||
renderNodeComponent,
|
||||
renderWorkflowComponent,
|
||||
renderWorkflowFlowComponent,
|
||||
renderWorkflowFlowHook,
|
||||
} from './workflow-test-env'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test components that read from workflow contexts
|
||||
@ -43,6 +50,12 @@ function NodeRenderer(props: { id: string, data: { title: string }, selected?: b
|
||||
)
|
||||
}
|
||||
|
||||
function FlowReader() {
|
||||
const nodes = useNodes()
|
||||
const showConfirm = useStore(s => s.showConfirm)
|
||||
return React.createElement('div', { 'data-testid': 'flow-reader' }, `${nodes.length}:${showConfirm ? 'confirm' : 'clear'}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -134,3 +147,30 @@ describe('renderNodeComponent', () => {
|
||||
expect(screen.getByTestId('node-store')).toHaveTextContent('test-node-1:hand')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderWorkflowFlowComponent', () => {
|
||||
it('should provide both ReactFlow and Workflow contexts', () => {
|
||||
renderWorkflowFlowComponent(React.createElement(FlowReader), {
|
||||
nodes: [
|
||||
createNode({ id: 'n-1' }),
|
||||
createNode({ id: 'n-2' }),
|
||||
],
|
||||
initialStoreState: { showConfirm: { title: 'Hey', onConfirm: () => {} } },
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('flow-reader')).toHaveTextContent('2:confirm')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderWorkflowFlowHook', () => {
|
||||
it('should render hooks inside a real ReactFlow provider', () => {
|
||||
const { result } = renderWorkflowFlowHook(() => useNodes(), {
|
||||
nodes: [
|
||||
createNode({ id: 'flow-1' }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0].id).toBe('flow-1')
|
||||
})
|
||||
})
|
||||
|
||||
@ -65,9 +65,11 @@ import type { Shape as HooksStoreShape } from '../hooks-store/store'
|
||||
import type { Shape } from '../store/workflow'
|
||||
import type { Edge, Node, WorkflowRunningData } from '../types'
|
||||
import type { WorkflowHistoryStoreApi } from '../workflow-history-store'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import isDeepEqual from 'fast-deep-equal'
|
||||
import * as React from 'react'
|
||||
import ReactFlow, { ReactFlowProvider } from 'reactflow'
|
||||
import { temporal } from 'zundo'
|
||||
import { create } from 'zustand'
|
||||
import { WorkflowContext } from '../context'
|
||||
@ -154,6 +156,13 @@ function createWorkflowWrapper(
|
||||
const historyCtxValue = historyConfig
|
||||
? createTestHistoryStoreContext(historyConfig)
|
||||
: undefined
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => {
|
||||
let inner: React.ReactNode = children
|
||||
@ -175,9 +184,13 @@ function createWorkflowWrapper(
|
||||
}
|
||||
|
||||
return React.createElement(
|
||||
WorkflowContext.Provider,
|
||||
{ value: stores.store },
|
||||
inner,
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(
|
||||
WorkflowContext.Provider,
|
||||
{ value: stores.store },
|
||||
inner,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -240,6 +253,104 @@ export function renderWorkflowComponent(
|
||||
return { ...renderResult, ...stores }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderWorkflowFlowComponent / renderWorkflowFlowHook — real ReactFlow wrappers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type WorkflowFlowOptions = WorkflowProviderOptions & {
|
||||
nodes?: Node[]
|
||||
edges?: Edge[]
|
||||
reactFlowProps?: Omit<React.ComponentProps<typeof ReactFlow>, 'children' | 'nodes' | 'edges'>
|
||||
canvasStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
type WorkflowFlowComponentTestOptions = Omit<RenderOptions, 'wrapper'> & WorkflowFlowOptions
|
||||
type WorkflowFlowHookTestOptions<P> = Omit<RenderHookOptions<P>, 'wrapper'> & WorkflowFlowOptions
|
||||
|
||||
function createWorkflowFlowWrapper(
|
||||
stores: StoreInstances,
|
||||
{
|
||||
historyStore: historyConfig,
|
||||
nodes = [],
|
||||
edges = [],
|
||||
reactFlowProps,
|
||||
canvasStyle,
|
||||
}: WorkflowFlowOptions,
|
||||
) {
|
||||
const workflowWrapper = createWorkflowWrapper(stores, historyConfig)
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => React.createElement(
|
||||
workflowWrapper,
|
||||
null,
|
||||
React.createElement(
|
||||
'div',
|
||||
{ style: { width: 800, height: 600, ...canvasStyle } },
|
||||
React.createElement(
|
||||
ReactFlowProvider,
|
||||
null,
|
||||
React.createElement(ReactFlow, { fitView: true, ...reactFlowProps, nodes, edges }),
|
||||
children,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function renderWorkflowFlowComponent(
|
||||
ui: React.ReactElement,
|
||||
options?: WorkflowFlowComponentTestOptions,
|
||||
): WorkflowComponentTestResult {
|
||||
const {
|
||||
initialStoreState,
|
||||
hooksStoreProps,
|
||||
historyStore,
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps,
|
||||
canvasStyle,
|
||||
...renderOptions
|
||||
} = options ?? {}
|
||||
|
||||
const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
|
||||
const wrapper = createWorkflowFlowWrapper(stores, {
|
||||
historyStore,
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps,
|
||||
canvasStyle,
|
||||
})
|
||||
|
||||
const renderResult = render(ui, { wrapper, ...renderOptions })
|
||||
return { ...renderResult, ...stores }
|
||||
}
|
||||
|
||||
export function renderWorkflowFlowHook<R, P = undefined>(
|
||||
hook: (props: P) => R,
|
||||
options?: WorkflowFlowHookTestOptions<P>,
|
||||
): WorkflowHookTestResult<R, P> {
|
||||
const {
|
||||
initialStoreState,
|
||||
hooksStoreProps,
|
||||
historyStore,
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps,
|
||||
canvasStyle,
|
||||
...rest
|
||||
} = options ?? {}
|
||||
|
||||
const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
|
||||
const wrapper = createWorkflowFlowWrapper(stores, {
|
||||
historyStore,
|
||||
nodes,
|
||||
edges,
|
||||
reactFlowProps,
|
||||
canvasStyle,
|
||||
})
|
||||
|
||||
const renderResult = renderHook(hook, { wrapper, ...rest })
|
||||
return { ...renderResult, ...stores }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderNodeComponent — convenience wrapper for node components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user