mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +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:
115
web/app/components/workflow/panel/__tests__/index.spec.tsx
Normal file
115
web/app/components/workflow/panel/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import type { PanelProps } from '../index'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import Panel from '../index'
|
||||
|
||||
const mockVersionHistoryPanel = vi.hoisted(() => vi.fn())
|
||||
|
||||
class MockResizeObserver implements ResizeObserver {
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
|
||||
constructor(_callback: ResizeObserverCallback) {}
|
||||
}
|
||||
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: () => (props: { latestVersionId?: string }) => {
|
||||
mockVersionHistoryPanel(props)
|
||||
return <div data-testid="version-history-panel">{props.latestVersionId}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const mod = await import('../../__tests__/reactflow-mock-state')
|
||||
const base = mod.createReactFlowModuleMock()
|
||||
|
||||
return {
|
||||
...base,
|
||||
useStore: vi.fn(selector => selector({
|
||||
getNodes: () => mod.rfState.nodes,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../env-panel', () => ({
|
||||
default: () => <div data-testid="env-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../nodes', () => ({
|
||||
Panel: ({ id }: { id: string }) => <div data-testid="node-panel">{id}</div>,
|
||||
}))
|
||||
|
||||
const versionHistoryPanelProps = {
|
||||
latestVersionId: 'version-1',
|
||||
restoreVersionUrl: (versionId: string) => `/workflows/${versionId}/restore`,
|
||||
} satisfies NonNullable<PanelProps['versionHistoryPanelProps']>
|
||||
|
||||
describe('Panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetReactFlowMockState()
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Version History Panel', () => {
|
||||
it('should render the version history panel when the panel is open and props are provided', () => {
|
||||
renderWorkflowComponent(
|
||||
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
|
||||
{
|
||||
initialStoreState: {
|
||||
showWorkflowVersionHistoryPanel: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('version-history-panel')).toHaveTextContent('version-1')
|
||||
expect(mockVersionHistoryPanel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
latestVersionId: 'version-1',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should not render the version history panel when the panel is open but props are missing', () => {
|
||||
renderWorkflowComponent(
|
||||
<Panel />,
|
||||
{
|
||||
initialStoreState: {
|
||||
showWorkflowVersionHistoryPanel: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
|
||||
expect(mockVersionHistoryPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not render the version history panel when the panel is closed', () => {
|
||||
rfState.nodes = [
|
||||
createNode({
|
||||
id: 'selected-node',
|
||||
data: {
|
||||
selected: true,
|
||||
},
|
||||
}),
|
||||
] as typeof rfState.nodes
|
||||
|
||||
renderWorkflowComponent(
|
||||
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
|
||||
{
|
||||
initialStoreState: {
|
||||
showWorkflowVersionHistoryPanel: false,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('node-panel')).toHaveTextContent('selected-node')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,301 @@
|
||||
import type { Shape as HooksStoreShape } from '../../hooks-store/store'
|
||||
import type { RunFile } from '../../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { createStartNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { InputVarType, WorkflowRunningStatus } from '../../types'
|
||||
import InputsPanel from '../inputs-panel'
|
||||
|
||||
const mockCheckInputsForm = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/check-input-forms-hooks', () => ({
|
||||
useCheckInputsForms: () => ({
|
||||
checkInputsForm: mockCheckInputsForm,
|
||||
}),
|
||||
}))
|
||||
|
||||
const fileSettingsWithImage = {
|
||||
enabled: true,
|
||||
image: {
|
||||
enabled: true,
|
||||
},
|
||||
allowed_file_upload_methods: [TransferMethod.remote_url],
|
||||
number_limits: 3,
|
||||
image_file_size_limit: 10,
|
||||
} satisfies FileUpload & { image_file_size_limit: number }
|
||||
|
||||
const uploadedRunFile = {
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
upload_file_id: 'file-2',
|
||||
} as unknown as RunFile
|
||||
|
||||
const uploadingRunFile = {
|
||||
transfer_method: TransferMethod.local_file,
|
||||
} as unknown as RunFile
|
||||
|
||||
const createHooksStoreProps = (
|
||||
overrides: Partial<HooksStoreShape> = {},
|
||||
): Partial<HooksStoreShape> => ({
|
||||
handleRun: vi.fn(),
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: fileSettingsWithImage,
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderInputsPanel = (
|
||||
startNode: ReturnType<typeof createStartNode>,
|
||||
options?: Omit<Parameters<typeof renderWorkflowFlowComponent>[1], 'nodes' | 'edges'>,
|
||||
onRun = vi.fn(),
|
||||
) =>
|
||||
renderWorkflowFlowComponent(
|
||||
<InputsPanel onRun={onRun} />,
|
||||
{
|
||||
nodes: [startNode],
|
||||
edges: [],
|
||||
...options,
|
||||
},
|
||||
)
|
||||
|
||||
describe('InputsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckInputsForm.mockReturnValue(true)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render current inputs, defaults, and the image uploader from the start node', () => {
|
||||
renderInputsPanel(
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
default: 'default question',
|
||||
},
|
||||
{
|
||||
type: InputVarType.number,
|
||||
variable: 'count',
|
||||
label: 'Count',
|
||||
required: false,
|
||||
default: '2',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
initialStoreState: {
|
||||
inputs: {
|
||||
question: 'overridden question',
|
||||
},
|
||||
},
|
||||
hooksStoreProps: createHooksStoreProps(),
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('overridden question')).toHaveFocus()
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(2)
|
||||
expect(screen.getByText('common.imageUploader.pasteImageLink')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should update workflow inputs and image files when users edit the form', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderInputsPanel(
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
hooksStoreProps: createHooksStoreProps(),
|
||||
},
|
||||
)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Question'), 'changed question')
|
||||
expect(store.getState().inputs).toEqual({ question: 'changed question' })
|
||||
|
||||
await user.click(screen.getByText('common.imageUploader.pasteImageLink'))
|
||||
await user.type(
|
||||
await screen.findByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder'),
|
||||
'https://example.com/image.png',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.ok' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().files).toEqual([{
|
||||
type: 'image',
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
url: 'https://example.com/image.png',
|
||||
upload_file_id: '',
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
it('should not start a run when input validation fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCheckInputsForm.mockReturnValue(false)
|
||||
const onRun = vi.fn()
|
||||
const handleRun = vi.fn()
|
||||
|
||||
renderInputsPanel(
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
default: 'default question',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
hooksStoreProps: createHooksStoreProps({ handleRun }),
|
||||
},
|
||||
onRun,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
|
||||
|
||||
expect(mockCheckInputsForm).toHaveBeenCalledWith(
|
||||
{ question: 'default question' },
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ variable: 'question' }),
|
||||
expect.objectContaining({ variable: '__image' }),
|
||||
]),
|
||||
)
|
||||
expect(onRun).not.toHaveBeenCalled()
|
||||
expect(handleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should start a run with processed inputs when validation succeeds', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRun = vi.fn()
|
||||
const handleRun = vi.fn()
|
||||
|
||||
renderInputsPanel(
|
||||
createStartNode({
|
||||
data: {
|
||||
variables: [
|
||||
{
|
||||
type: InputVarType.textInput,
|
||||
variable: 'question',
|
||||
label: 'Question',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: InputVarType.checkbox,
|
||||
variable: 'confirmed',
|
||||
label: 'Confirmed',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
initialStoreState: {
|
||||
inputs: {
|
||||
question: 'run this',
|
||||
confirmed: 'truthy',
|
||||
},
|
||||
files: [uploadedRunFile],
|
||||
},
|
||||
hooksStoreProps: createHooksStoreProps({
|
||||
handleRun,
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
onRun,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
|
||||
|
||||
expect(onRun).toHaveBeenCalledTimes(1)
|
||||
expect(handleRun).toHaveBeenCalledWith({
|
||||
inputs: {
|
||||
question: 'run this',
|
||||
confirmed: true,
|
||||
},
|
||||
files: [uploadedRunFile],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled States', () => {
|
||||
it('should disable the run button while a local file is still uploading', () => {
|
||||
renderInputsPanel(createStartNode(), {
|
||||
initialStoreState: {
|
||||
files: [uploadingRunFile],
|
||||
},
|
||||
hooksStoreProps: createHooksStoreProps({
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable the run button while the workflow is already running', () => {
|
||||
renderInputsPanel(createStartNode(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: {
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Running,
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
},
|
||||
tracing: [],
|
||||
},
|
||||
},
|
||||
hooksStoreProps: createHooksStoreProps(),
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
163
web/app/components/workflow/panel/__tests__/record.spec.tsx
Normal file
163
web/app/components/workflow/panel/__tests__/record.spec.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import type { WorkflowRunDetailResponse } from '@/models/log'
|
||||
import { act, screen } from '@testing-library/react'
|
||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import Record from '../record'
|
||||
|
||||
const mockHandleUpdateWorkflowCanvas = vi.fn()
|
||||
const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number) => finishedAt ? ' (Finished)' : ' (Running)')
|
||||
|
||||
let latestGetResultCallback: ((res: WorkflowRunDetailResponse) => void) | undefined
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowUpdate: () => ({
|
||||
handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run', () => ({
|
||||
default: ({
|
||||
runDetailUrl,
|
||||
tracingListUrl,
|
||||
getResultCallback,
|
||||
}: {
|
||||
runDetailUrl: string
|
||||
tracingListUrl: string
|
||||
getResultCallback: (res: WorkflowRunDetailResponse) => void
|
||||
}) => {
|
||||
latestGetResultCallback = getResultCallback
|
||||
return (
|
||||
<div
|
||||
data-run-detail-url={runDetailUrl}
|
||||
data-testid="run"
|
||||
data-tracing-list-url={tracingListUrl}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt),
|
||||
}))
|
||||
|
||||
const createRunDetail = (overrides: Partial<WorkflowRunDetailResponse> = {}): WorkflowRunDetailResponse => ({
|
||||
id: 'run-1',
|
||||
version: '1',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
inputs: '{}',
|
||||
inputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
outputs: '{}',
|
||||
outputs_truncated: false,
|
||||
total_steps: 1,
|
||||
created_by_role: 'account',
|
||||
created_at: 1,
|
||||
finished_at: 2,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Record', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestGetResultCallback = undefined
|
||||
})
|
||||
|
||||
it('renders the run title and passes run and trace URLs to the run panel', () => {
|
||||
const getWorkflowRunAndTraceUrl = vi.fn((runId?: string) => ({
|
||||
runUrl: `/runs/${runId}`,
|
||||
traceUrl: `/traces/${runId}`,
|
||||
}))
|
||||
|
||||
renderWorkflowComponent(<Record />, {
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'run-1',
|
||||
status: 'succeeded',
|
||||
finished_at: 1700000000000,
|
||||
},
|
||||
},
|
||||
hooksStoreProps: {
|
||||
getWorkflowRunAndTraceUrl,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('Test Run (Finished)')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('run')).toHaveAttribute('data-run-detail-url', '/runs/run-1')
|
||||
expect(screen.getByTestId('run')).toHaveAttribute('data-tracing-list-url', '/traces/run-1')
|
||||
expect(getWorkflowRunAndTraceUrl).toHaveBeenCalledTimes(2)
|
||||
expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(1, 'run-1')
|
||||
expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(2, 'run-1')
|
||||
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1700000000000)
|
||||
})
|
||||
|
||||
it('updates the workflow canvas with a fallback viewport when the response omits one', () => {
|
||||
const nodes = [createNode({ id: 'node-1' })]
|
||||
const edges = [createEdge({ id: 'edge-1' })]
|
||||
|
||||
renderWorkflowComponent(<Record />, {
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'run-1',
|
||||
status: 'succeeded',
|
||||
},
|
||||
},
|
||||
hooksStoreProps: {
|
||||
getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }),
|
||||
},
|
||||
})
|
||||
|
||||
expect(latestGetResultCallback).toBeDefined()
|
||||
|
||||
act(() => {
|
||||
latestGetResultCallback?.(createRunDetail({
|
||||
graph: {
|
||||
nodes,
|
||||
edges,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes,
|
||||
edges,
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the response viewport when one is available', () => {
|
||||
const nodes = [createNode({ id: 'node-1' })]
|
||||
const edges = [createEdge({ id: 'edge-1' })]
|
||||
const viewport = { x: 12, y: 24, zoom: 0.75 }
|
||||
|
||||
renderWorkflowComponent(<Record />, {
|
||||
initialStoreState: {
|
||||
historyWorkflowData: {
|
||||
id: 'run-1',
|
||||
status: 'succeeded',
|
||||
},
|
||||
},
|
||||
hooksStoreProps: {
|
||||
getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
latestGetResultCallback?.(createRunDetail({
|
||||
graph: {
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,135 @@
|
||||
import type { ChatItemInTree } from '@/app/components/base/chat/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useChat } from '../hooks'
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
sseGet: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useInvalidAllLastRun: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
submitHumanInputForm: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({ notify: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({ getState: () => ({}) }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks', () => ({
|
||||
useWorkflowRun: () => ({ handleRun: vi.fn() }),
|
||||
useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks-store', () => ({
|
||||
useHooksStore: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setIterTimes: vi.fn(),
|
||||
setLoopTimes: vi.fn(),
|
||||
inputs: {},
|
||||
}),
|
||||
}),
|
||||
useStore: () => vi.fn(),
|
||||
}))
|
||||
|
||||
describe('workflow debug useChat – opening statement stability', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return empty chatList when config has no opening_statement', () => {
|
||||
const { result } = renderHook(() => useChat({}))
|
||||
expect(result.current.chatList).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty chatList when opening_statement is an empty string', () => {
|
||||
const { result } = renderHook(() => useChat({ opening_statement: '' }))
|
||||
expect(result.current.chatList).toEqual([])
|
||||
})
|
||||
|
||||
it('should use stable id "opening-statement" instead of Date.now()', () => {
|
||||
const config = { opening_statement: 'Welcome!' }
|
||||
|
||||
const { result } = renderHook(() => useChat(config))
|
||||
expect(result.current.chatList[0].id).toBe('opening-statement')
|
||||
})
|
||||
|
||||
it('should preserve reference when inputs change but produce identical content', () => {
|
||||
const config = {
|
||||
opening_statement: 'Hello {{name}}',
|
||||
suggested_questions: ['Ask {{name}}'],
|
||||
}
|
||||
const formSettings = { inputs: { name: 'Alice' }, inputsForm: [] }
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ fs }) => useChat(config, fs),
|
||||
{ initialProps: { fs: formSettings } },
|
||||
)
|
||||
|
||||
const openerBefore = result.current.chatList[0]
|
||||
expect(openerBefore.content).toBe('Hello Alice')
|
||||
|
||||
rerender({ fs: { inputs: { name: 'Alice' }, inputsForm: [] } })
|
||||
|
||||
const openerAfter = result.current.chatList[0]
|
||||
expect(openerAfter).toBe(openerBefore)
|
||||
})
|
||||
|
||||
it('should create new object when content actually changes', () => {
|
||||
const config = {
|
||||
opening_statement: 'Hello {{name}}',
|
||||
suggested_questions: [],
|
||||
}
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ fs }) => useChat(config, fs),
|
||||
{ initialProps: { fs: { inputs: { name: 'Alice' }, inputsForm: [] } } },
|
||||
)
|
||||
|
||||
const openerBefore = result.current.chatList[0]
|
||||
expect(openerBefore.content).toBe('Hello Alice')
|
||||
|
||||
rerender({ fs: { inputs: { name: 'Bob' }, inputsForm: [] } })
|
||||
|
||||
const openerAfter = result.current.chatList[0]
|
||||
expect(openerAfter.content).toBe('Hello Bob')
|
||||
expect(openerAfter).not.toBe(openerBefore)
|
||||
})
|
||||
|
||||
it('should preserve reference for existing opening statement in prevChatTree', () => {
|
||||
const config = {
|
||||
opening_statement: 'Updated welcome',
|
||||
suggested_questions: ['S1'],
|
||||
}
|
||||
const prevChatTree = [{
|
||||
id: 'opening-statement',
|
||||
content: 'old',
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
suggestedQuestions: [],
|
||||
}]
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ cfg }) => useChat(cfg, undefined, prevChatTree as ChatItemInTree[]),
|
||||
{ initialProps: { cfg: config } },
|
||||
)
|
||||
|
||||
const openerBefore = result.current.chatList[0]
|
||||
expect(openerBefore.content).toBe('Updated welcome')
|
||||
|
||||
rerender({ cfg: config })
|
||||
|
||||
const openerAfter = result.current.chatList[0]
|
||||
expect(openerAfter).toBe(openerBefore)
|
||||
})
|
||||
})
|
||||
@ -91,31 +91,54 @@ export const useChat = (
|
||||
return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || [])
|
||||
}, [formSettings?.inputs, formSettings?.inputsForm])
|
||||
|
||||
const processedOpeningContent = config?.opening_statement
|
||||
? getIntroduction(config.opening_statement)
|
||||
: undefined
|
||||
const processedSuggestionsKey = config?.suggested_questions
|
||||
? JSON.stringify(config.suggested_questions.map((q: string) => getIntroduction(q)))
|
||||
: undefined
|
||||
|
||||
const openingStatementItem = useMemo<ChatItemInTree | null>(() => {
|
||||
if (!processedOpeningContent)
|
||||
return null
|
||||
return {
|
||||
id: 'opening-statement',
|
||||
content: processedOpeningContent,
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
suggestedQuestions: processedSuggestionsKey
|
||||
? JSON.parse(processedSuggestionsKey) as string[]
|
||||
: undefined,
|
||||
}
|
||||
}, [processedOpeningContent, processedSuggestionsKey])
|
||||
|
||||
const threadOpener = useMemo(
|
||||
() => threadMessages.find(item => item.isOpeningStatement) ?? null,
|
||||
[threadMessages],
|
||||
)
|
||||
|
||||
const mergedOpeningItem = useMemo<ChatItemInTree | null>(() => {
|
||||
if (!threadOpener || !openingStatementItem)
|
||||
return null
|
||||
return {
|
||||
...threadOpener,
|
||||
content: openingStatementItem.content,
|
||||
suggestedQuestions: openingStatementItem.suggestedQuestions,
|
||||
}
|
||||
}, [threadOpener, openingStatementItem])
|
||||
|
||||
/** Final chat list that will be rendered */
|
||||
const chatList = useMemo(() => {
|
||||
const ret = [...threadMessages]
|
||||
if (config?.opening_statement) {
|
||||
if (openingStatementItem) {
|
||||
const index = threadMessages.findIndex(item => item.isOpeningStatement)
|
||||
|
||||
if (index > -1) {
|
||||
ret[index] = {
|
||||
...ret[index],
|
||||
content: getIntroduction(config.opening_statement),
|
||||
suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)),
|
||||
}
|
||||
}
|
||||
else {
|
||||
ret.unshift({
|
||||
id: `${Date.now()}`,
|
||||
content: getIntroduction(config.opening_statement),
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)),
|
||||
})
|
||||
}
|
||||
if (index > -1 && mergedOpeningItem)
|
||||
ret[index] = mergedOpeningItem
|
||||
else if (index === -1)
|
||||
ret.unshift(openingStatementItem)
|
||||
}
|
||||
return ret
|
||||
}, [threadMessages, config?.opening_statement, getIntroduction, config?.suggested_questions])
|
||||
}, [threadMessages, openingStatementItem, mergedOpeningItem])
|
||||
|
||||
useEffect(() => {
|
||||
setAutoFreeze(false)
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { FC } from 'react'
|
||||
import type { VersionHistoryPanelProps } from '@/app/components/workflow/panel/version-history-panel'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { memo, useCallback, useEffect, useRef } from 'react'
|
||||
import { useStore as useReactflow } from 'reactflow'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { Panel as NodePanel } from '../nodes'
|
||||
import { useStore } from '../store'
|
||||
@ -145,7 +145,7 @@ const Panel: FC<PanelProps> = ({
|
||||
components?.right
|
||||
}
|
||||
{
|
||||
showWorkflowVersionHistoryPanel && (
|
||||
showWorkflowVersionHistoryPanel && versionHistoryPanelProps && (
|
||||
<VersionHistoryPanel {...versionHistoryPanelProps} />
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Empty from '../empty'
|
||||
|
||||
describe('VersionHistory Empty', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Empty state should show the reset action and forward user clicks.
|
||||
describe('User Interactions', () => {
|
||||
it('should call onResetFilter when the reset button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onResetFilter = vi.fn()
|
||||
|
||||
render(<Empty onResetFilter={onResetFilter} />)
|
||||
|
||||
expect(screen.getByText('workflow.versionHistory.filter.empty')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.versionHistory.filter.reset' }))
|
||||
|
||||
expect(onResetFilter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,269 @@
|
||||
import type { Shape } from '../../../store'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useEffect } from 'react'
|
||||
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
|
||||
|
||||
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
|
||||
const mockHandleLoadBackupDraft = vi.fn()
|
||||
const mockHandleRefreshWorkflowDraft = vi.fn()
|
||||
const mockRestoreWorkflow = vi.fn()
|
||||
const mockSetCurrentVersion = vi.fn()
|
||||
const mockSetShowWorkflowVersionHistoryPanel = vi.fn()
|
||||
const mockWorkflowStoreSetState = vi.fn()
|
||||
|
||||
const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
|
||||
id: 'version-id',
|
||||
version: WorkflowVersion.Draft,
|
||||
graph: { nodes: [], edges: [] },
|
||||
features: {
|
||||
opening_statement: '',
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: { enabled: false },
|
||||
text_to_speech: { enabled: false },
|
||||
speech_to_text: { enabled: false },
|
||||
retriever_resource: { enabled: false },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
file_upload: { image: { enabled: false } },
|
||||
},
|
||||
created_at: Date.now() / 1000,
|
||||
created_by: { id: 'user-1', name: 'User 1', email: 'user-1@example.com' },
|
||||
hash: 'test-hash',
|
||||
updated_at: Date.now() / 1000,
|
||||
updated_by: { id: 'user-1', name: 'User 1', email: 'user-1@example.com' },
|
||||
tool_published: false,
|
||||
environment_variables: [],
|
||||
marked_name: '',
|
||||
marked_comment: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
let mockCurrentVersion: VersionHistory | null = null
|
||||
|
||||
type MockVersionStoreState = Pick<Shape, 'currentVersion' | 'setCurrentVersion' | 'setShowWorkflowVersionHistoryPanel'>
|
||||
type MockRestoreConfirmModalProps = {
|
||||
isOpen: boolean
|
||||
versionInfo: VersionHistory
|
||||
onRestore: (item: VersionHistory) => void
|
||||
}
|
||||
type MockVersionHistoryItemProps = {
|
||||
item: VersionHistory
|
||||
onClick: (item: VersionHistory) => void
|
||||
handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void
|
||||
}
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => ({ id: 'test-user-id' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }),
|
||||
useInvalidAllLastRun: () => vi.fn(),
|
||||
useResetWorkflowVersionHistory: () => vi.fn(),
|
||||
useRestoreWorkflow: () => ({ mutateAsync: mockRestoreWorkflow }),
|
||||
useUpdateWorkflow: () => ({ mutateAsync: vi.fn() }),
|
||||
useWorkflowVersionHistory: () => ({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
items: [
|
||||
createVersionHistory({
|
||||
id: 'draft-version-id',
|
||||
version: WorkflowVersion.Draft,
|
||||
}),
|
||||
createVersionHistory({
|
||||
id: 'published-version-id',
|
||||
version: '2024-01-01T00:00:00Z',
|
||||
marked_name: 'v1.0',
|
||||
marked_comment: 'First release',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks', () => ({
|
||||
useDSL: () => ({ handleExportDSL: vi.fn() }),
|
||||
useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft }),
|
||||
useWorkflowRun: () => ({
|
||||
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
|
||||
handleLoadBackupDraft: mockHandleLoadBackupDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks-store', () => ({
|
||||
useHooksStore: () => ({
|
||||
flowId: 'test-flow-id',
|
||||
flowType: 'workflow',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../store', () => ({
|
||||
useStore: <T,>(selector: (state: MockVersionStoreState) => T) => {
|
||||
const state: MockVersionStoreState = {
|
||||
setShowWorkflowVersionHistoryPanel: mockSetShowWorkflowVersionHistoryPanel,
|
||||
currentVersion: mockCurrentVersion,
|
||||
setCurrentVersion: mockSetCurrentVersion,
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
deleteAllInspectVars: vi.fn(),
|
||||
setShowWorkflowVersionHistoryPanel: mockSetShowWorkflowVersionHistoryPanel,
|
||||
setCurrentVersion: mockSetCurrentVersion,
|
||||
}),
|
||||
setState: mockWorkflowStoreSetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../delete-confirm-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../restore-confirm-modal', () => ({
|
||||
default: (props: MockRestoreConfirmModalProps) => {
|
||||
const MockRestoreConfirmModal = () => {
|
||||
const { isOpen, versionInfo, onRestore } = props
|
||||
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return <button onClick={() => onRestore(versionInfo)}>confirm restore</button>
|
||||
}
|
||||
|
||||
return <MockRestoreConfirmModal />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../version-history-item', () => ({
|
||||
default: (props: MockVersionHistoryItemProps) => {
|
||||
const MockVersionHistoryItem = () => {
|
||||
const { item, onClick, handleClickMenuItem } = props
|
||||
|
||||
useEffect(() => {
|
||||
if (item.version === WorkflowVersion.Draft)
|
||||
onClick(item)
|
||||
}, [item, onClick])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => onClick(item)}>{item.marked_name || item.version}</button>
|
||||
{item.version !== WorkflowVersion.Draft && (
|
||||
<button onClick={() => handleClickMenuItem(VersionHistoryContextMenuOptions.restore)}>
|
||||
{`restore-${item.id}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <MockVersionHistoryItem />
|
||||
},
|
||||
}))
|
||||
|
||||
describe('VersionHistoryPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCurrentVersion = null
|
||||
})
|
||||
|
||||
describe('Version Click Behavior', () => {
|
||||
it('should call handleLoadBackupDraft when draft version is selected on mount', async () => {
|
||||
const { VersionHistoryPanel } = await import('../index')
|
||||
|
||||
render(
|
||||
<VersionHistoryPanel
|
||||
latestVersionId="published-version-id"
|
||||
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockHandleLoadBackupDraft).toHaveBeenCalled()
|
||||
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleRestoreFromPublishedWorkflow when clicking published version', async () => {
|
||||
const { VersionHistoryPanel } = await import('../index')
|
||||
|
||||
render(
|
||||
<VersionHistoryPanel
|
||||
latestVersionId="published-version-id"
|
||||
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
|
||||
/>,
|
||||
)
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
fireEvent.click(screen.getByText('v1.0'))
|
||||
|
||||
expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalled()
|
||||
expect(mockHandleLoadBackupDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should set current version before confirming restore from context menu', async () => {
|
||||
const { VersionHistoryPanel } = await import('../index')
|
||||
|
||||
render(
|
||||
<VersionHistoryPanel
|
||||
latestVersionId="published-version-id"
|
||||
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
|
||||
/>,
|
||||
)
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
fireEvent.click(screen.getByText('restore-published-version-id'))
|
||||
fireEvent.click(screen.getByText('confirm restore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCurrentVersion).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'published-version-id',
|
||||
}))
|
||||
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore')
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isRestoring: false })
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ backupDraft: undefined })
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep restore mode backup state when restore request fails', async () => {
|
||||
const { VersionHistoryPanel } = await import('../index')
|
||||
mockRestoreWorkflow.mockRejectedValueOnce(new Error('restore failed'))
|
||||
mockCurrentVersion = createVersionHistory({
|
||||
id: 'draft-version-id',
|
||||
version: WorkflowVersion.Draft,
|
||||
})
|
||||
|
||||
render(
|
||||
<VersionHistoryPanel
|
||||
latestVersionId="published-version-id"
|
||||
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
|
||||
/>,
|
||||
)
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
fireEvent.click(screen.getByText('restore-published-version-id'))
|
||||
fireEvent.click(screen.getByText('confirm restore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore')
|
||||
})
|
||||
|
||||
expect(mockWorkflowStoreSetState).not.toHaveBeenCalledWith({ isRestoring: false })
|
||||
expect(mockWorkflowStoreSetState).not.toHaveBeenCalledWith({ backupDraft: undefined })
|
||||
expect(mockSetCurrentVersion).not.toHaveBeenCalled()
|
||||
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,151 @@
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
|
||||
import VersionHistoryItem from '../version-history-item'
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: { pipelineId?: string }) => unknown) => selector({ pipelineId: undefined }),
|
||||
}))
|
||||
|
||||
const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
|
||||
id: 'version-1',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: undefined,
|
||||
},
|
||||
features: {},
|
||||
created_at: 1710000000,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
hash: 'hash-1',
|
||||
updated_at: 1710000000,
|
||||
updated_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
tool_published: false,
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
rag_pipeline_variables: undefined,
|
||||
version: '2024-01-01T00:00:00Z',
|
||||
marked_name: 'Release 1',
|
||||
marked_comment: 'Initial release',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('VersionHistoryItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Draft items should auto-select on mount and hide published-only metadata.
|
||||
describe('Draft Behavior', () => {
|
||||
it('should auto-select the draft version on mount', async () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(
|
||||
<VersionHistoryItem
|
||||
item={createVersionHistory({
|
||||
id: 'draft-version',
|
||||
version: WorkflowVersion.Draft,
|
||||
marked_name: '',
|
||||
marked_comment: '',
|
||||
})}
|
||||
currentVersion={null}
|
||||
latestVersionId="latest-version"
|
||||
onClick={onClick}
|
||||
handleClickMenuItem={vi.fn()}
|
||||
isLast={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.versionHistory.currentDraft')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClick).toHaveBeenCalledWith(expect.objectContaining({
|
||||
version: WorkflowVersion.Draft,
|
||||
}))
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Initial release')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Published items should expose metadata and the hover context menu.
|
||||
describe('Published Items', () => {
|
||||
it('should open the context menu for a latest named version and forward restore', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleClickMenuItem = vi.fn()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(
|
||||
<VersionHistoryItem
|
||||
item={createVersionHistory()}
|
||||
currentVersion={null}
|
||||
latestVersionId="version-1"
|
||||
onClick={onClick}
|
||||
handleClickMenuItem={handleClickMenuItem}
|
||||
isLast={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const title = screen.getByText('Release 1')
|
||||
const itemContainer = title.closest('.group')
|
||||
if (!itemContainer)
|
||||
throw new Error('Expected version history item container')
|
||||
|
||||
fireEvent.mouseEnter(itemContainer)
|
||||
|
||||
const triggerButton = await screen.findByRole('button')
|
||||
await user.click(triggerButton)
|
||||
|
||||
expect(screen.getByText('workflow.versionHistory.latest')).toBeInTheDocument()
|
||||
expect(screen.getByText('Initial release')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Alice$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.restore')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.versionHistory.editVersionInfo')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.export')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.versionHistory.copyId')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
|
||||
const restoreItem = screen.getByText('workflow.common.restore').closest('.cursor-pointer')
|
||||
if (!restoreItem)
|
||||
throw new Error('Expected restore menu item')
|
||||
|
||||
fireEvent.click(restoreItem)
|
||||
|
||||
expect(handleClickMenuItem).toHaveBeenCalledTimes(1)
|
||||
expect(handleClickMenuItem).toHaveBeenCalledWith(
|
||||
VersionHistoryContextMenuOptions.restore,
|
||||
VersionHistoryContextMenuOptions.restore,
|
||||
)
|
||||
})
|
||||
|
||||
it('should ignore clicks when the item is already selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
const item = createVersionHistory()
|
||||
|
||||
render(
|
||||
<VersionHistoryItem
|
||||
item={item}
|
||||
currentVersion={item}
|
||||
latestVersionId="other-version"
|
||||
onClick={onClick}
|
||||
handleClickMenuItem={vi.fn()}
|
||||
isLast
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Release 1'))
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,102 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { WorkflowVersionFilterOptions } from '../../../../types'
|
||||
import FilterItem from '../filter-item'
|
||||
import FilterSwitch from '../filter-switch'
|
||||
import Filter from '../index'
|
||||
|
||||
describe('VersionHistory Filter Components', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The standalone switch should reflect state and emit checked changes.
|
||||
describe('FilterSwitch', () => {
|
||||
it('should render the switch label and emit toggled value', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleSwitch = vi.fn()
|
||||
|
||||
render(<FilterSwitch enabled={false} handleSwitch={handleSwitch} />)
|
||||
|
||||
expect(screen.getByText('workflow.versionHistory.filter.onlyShowNamedVersions')).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await user.click(screen.getByRole('switch'))
|
||||
|
||||
expect(handleSwitch).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Filter items should show the current selection and forward the option key.
|
||||
describe('FilterItem', () => {
|
||||
it('should call onClick with the selected filter key', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
|
||||
const { container } = render(
|
||||
<FilterItem
|
||||
item={{
|
||||
key: WorkflowVersionFilterOptions.onlyYours,
|
||||
name: 'Only Yours',
|
||||
}}
|
||||
isSelected
|
||||
onClick={onClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Only Yours')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Only Yours'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours)
|
||||
})
|
||||
})
|
||||
|
||||
// The composed filter popover should open, list options, and delegate actions.
|
||||
describe('Filter', () => {
|
||||
it('should open the menu and forward option and switch actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClickFilterItem = vi.fn()
|
||||
const handleSwitch = vi.fn()
|
||||
|
||||
const { container } = render(
|
||||
<Filter
|
||||
filterValue={WorkflowVersionFilterOptions.all}
|
||||
isOnlyShowNamedVersions={false}
|
||||
onClickFilterItem={onClickFilterItem}
|
||||
handleSwitch={handleSwitch}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = container.querySelector('.h-6.w-6')
|
||||
if (!trigger)
|
||||
throw new Error('Expected filter trigger to exist')
|
||||
|
||||
await user.click(trigger)
|
||||
|
||||
expect(screen.getByText('workflow.versionHistory.filter.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.versionHistory.filter.onlyYours')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.versionHistory.filter.onlyYours'))
|
||||
expect(onClickFilterItem).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
expect(handleSwitch).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should mark the trigger as active when a filter is applied', () => {
|
||||
const { container } = render(
|
||||
<Filter
|
||||
filterValue={WorkflowVersionFilterOptions.onlyYours}
|
||||
isOnlyShowNamedVersions={false}
|
||||
onClickFilterItem={vi.fn()}
|
||||
handleSwitch={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.bg-state-accent-active-alt')).toBeInTheDocument()
|
||||
expect(container.querySelector('.text-text-accent')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -8,10 +8,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import VersionInfoModal from '@/app/components/app/app-publisher/version-info-modal'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
|
||||
import { useDSL, useLeaderRestore, useWorkflowRun } from '../../hooks'
|
||||
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
|
||||
import { useDSL, useLeaderRestore, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks'
|
||||
import { useHooksStore } from '../../hooks-store'
|
||||
import { useStore, useWorkflowStore } from '../../store'
|
||||
import { VersionHistoryContextMenuOptions, WorkflowVersion, WorkflowVersionFilterOptions } from '../../types'
|
||||
@ -28,12 +28,14 @@ const INITIAL_PAGE = 1
|
||||
export type VersionHistoryPanelProps = {
|
||||
getVersionListUrl?: string
|
||||
deleteVersionUrl?: (versionId: string) => string
|
||||
restoreVersionUrl: (versionId: string) => string
|
||||
updateVersionUrl?: (versionId: string) => string
|
||||
latestVersionId?: string
|
||||
}
|
||||
export const VersionHistoryPanel = ({
|
||||
getVersionListUrl,
|
||||
deleteVersionUrl,
|
||||
restoreVersionUrl,
|
||||
updateVersionUrl,
|
||||
latestVersionId,
|
||||
}: VersionHistoryPanelProps) => {
|
||||
@ -47,6 +49,7 @@ export const VersionHistoryPanel = ({
|
||||
const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun()
|
||||
const { requestRestore } = useLeaderRestore()
|
||||
const featuresStore = useFeaturesStore()
|
||||
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
|
||||
const { handleExportDSL } = useDSL()
|
||||
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
|
||||
const currentVersion = useStore(s => s.currentVersion)
|
||||
@ -120,10 +123,7 @@ export const VersionHistoryPanel = ({
|
||||
break
|
||||
case VersionHistoryContextMenuOptions.copyId:
|
||||
copy(item.id)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('versionHistory.action.copyIdSuccess', { ns: 'workflow' }),
|
||||
})
|
||||
toast.success(t('versionHistory.action.copyIdSuccess', { ns: 'workflow' }))
|
||||
break
|
||||
case VersionHistoryContextMenuOptions.exportDSL:
|
||||
handleExportDSL?.(false, item.id, item.features?.sandbox?.enabled === true)
|
||||
@ -146,8 +146,9 @@ export const VersionHistoryPanel = ({
|
||||
}, [])
|
||||
|
||||
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
|
||||
const { mutateAsync: restoreWorkflow } = useRestoreWorkflow()
|
||||
|
||||
const handleRestore = useCallback((item: VersionHistory) => {
|
||||
const handleRestore = useCallback(async (item: VersionHistory) => {
|
||||
setShowWorkflowVersionHistoryPanel(false)
|
||||
handleRestoreFromPublishedWorkflow(item)
|
||||
workflowStore.setState({ isRestoring: false })
|
||||
@ -173,18 +174,12 @@ export const VersionHistoryPanel = ({
|
||||
conversationVariables,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
|
||||
})
|
||||
toast.success(t('versionHistory.action.restoreSuccess', { ns: 'workflow' }))
|
||||
deleteAllInspectVars()
|
||||
invalidAllLastRun()
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
|
||||
})
|
||||
toast.error(t('versionHistory.action.restoreFailure', { ns: 'workflow' }))
|
||||
},
|
||||
onSettled: () => {
|
||||
resetWorkflowVersionHistory()
|
||||
@ -198,19 +193,13 @@ export const VersionHistoryPanel = ({
|
||||
await deleteWorkflow(deleteVersionUrl?.(id) || '', {
|
||||
onSuccess: () => {
|
||||
setDeleteConfirmOpen(false)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('versionHistory.action.deleteSuccess', { ns: 'workflow' }),
|
||||
})
|
||||
toast.success(t('versionHistory.action.deleteSuccess', { ns: 'workflow' }))
|
||||
resetWorkflowVersionHistory()
|
||||
deleteAllInspectVars()
|
||||
invalidAllLastRun()
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('versionHistory.action.deleteFailure', { ns: 'workflow' }),
|
||||
})
|
||||
toast.error(t('versionHistory.action.deleteFailure', { ns: 'workflow' }))
|
||||
},
|
||||
onSettled: () => {
|
||||
setDeleteConfirmOpen(false)
|
||||
@ -228,17 +217,11 @@ export const VersionHistoryPanel = ({
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setEditModalOpen(false)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('versionHistory.action.updateSuccess', { ns: 'workflow' }),
|
||||
})
|
||||
toast.success(t('versionHistory.action.updateSuccess', { ns: 'workflow' }))
|
||||
resetWorkflowVersionHistory()
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('versionHistory.action.updateFailure', { ns: 'workflow' }),
|
||||
})
|
||||
toast.error(t('versionHistory.action.updateFailure', { ns: 'workflow' }))
|
||||
},
|
||||
onSettled: () => {
|
||||
setEditModalOpen(false)
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Loading from '../index'
|
||||
import Item from '../item'
|
||||
|
||||
describe('VersionHistory Loading', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Individual skeleton items should hide optional rows based on edge flags.
|
||||
describe('Item', () => {
|
||||
it('should hide the release note placeholder for the first row', () => {
|
||||
const { container } = render(
|
||||
<Item
|
||||
titleWidth="w-1/3"
|
||||
releaseNotesWidth="w-3/4"
|
||||
isFirst
|
||||
isLast={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-20')).toHaveLength(1)
|
||||
expect(container.querySelector('.bg-divider-subtle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the timeline connector for the last row', () => {
|
||||
const { container } = render(
|
||||
<Item
|
||||
titleWidth="w-2/5"
|
||||
releaseNotesWidth="w-4/6"
|
||||
isFirst={false}
|
||||
isLast
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-20')).toHaveLength(2)
|
||||
expect(container.querySelector('.absolute.left-4.top-6')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// The loading list should render the configured number of timeline skeleton rows.
|
||||
describe('Loading List', () => {
|
||||
it('should render eight loading rows with the overlay mask', () => {
|
||||
const { container } = render(<Loading />)
|
||||
|
||||
expect(container.querySelector('.bg-dataset-chunk-list-mask-bg')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.relative.flex.gap-x-1.p-2')).toHaveLength(8)
|
||||
expect(container.querySelectorAll('.opacity-20')).toHaveLength(15)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -11,9 +11,9 @@ import Button from '@/app/components/base/button'
|
||||
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { submitHumanInputForm } from '@/service/workflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Toast from '../../base/toast'
|
||||
import {
|
||||
useWorkflowInteractions,
|
||||
} from '../hooks'
|
||||
@ -239,7 +239,7 @@ const WorkflowPreview = () => {
|
||||
copy(content)
|
||||
else
|
||||
copy(JSON.stringify(content))
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-clipboard-line h-3.5 w-3.5" />
|
||||
|
||||
Reference in New Issue
Block a user