mirror of
https://github.com/langgenius/dify.git
synced 2026-03-20 22:17:58 +08:00
Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing
This commit is contained in:
2
.github/workflows/web-tests.yml
vendored
2
.github/workflows/web-tests.yml
vendored
@ -72,7 +72,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge reports
|
||||
run: pnpm vitest --merge-reports --coverage --silent=passed-only
|
||||
run: pnpm vitest --merge-reports --reporter=json --reporter=agent --coverage
|
||||
|
||||
- name: Coverage Summary
|
||||
if: always()
|
||||
|
||||
@ -188,7 +188,6 @@ VECTOR_INDEX_NAME_PREFIX=Vector_index
|
||||
# Weaviate configuration
|
||||
WEAVIATE_ENDPOINT=http://localhost:8080
|
||||
WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
|
||||
WEAVIATE_GRPC_ENABLED=false
|
||||
WEAVIATE_BATCH_SIZE=100
|
||||
WEAVIATE_TOKENIZATION=word
|
||||
|
||||
|
||||
@ -17,11 +17,6 @@ class WeaviateConfig(BaseSettings):
|
||||
default=None,
|
||||
)
|
||||
|
||||
WEAVIATE_GRPC_ENABLED: bool = Field(
|
||||
description="Whether to enable gRPC for Weaviate connection (True for gRPC, False for HTTP)",
|
||||
default=True,
|
||||
)
|
||||
|
||||
WEAVIATE_GRPC_ENDPOINT: str | None = Field(
|
||||
description="URL of the Weaviate gRPC server (e.g., 'grpc://localhost:50051' or 'grpcs://weaviate.example.com:443')",
|
||||
default=None,
|
||||
|
||||
@ -60,7 +60,6 @@ VECTOR_STORE=weaviate
|
||||
# Weaviate configuration
|
||||
WEAVIATE_ENDPOINT=http://localhost:8080
|
||||
WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
|
||||
WEAVIATE_GRPC_ENABLED=false
|
||||
WEAVIATE_BATCH_SIZE=100
|
||||
WEAVIATE_TOKENIZATION=word
|
||||
|
||||
|
||||
@ -38,7 +38,6 @@ BASE_API_AND_DOCKER_CONFIG_SET_DIFF = {
|
||||
"UPSTASH_VECTOR_URL",
|
||||
"USING_UGC_INDEX",
|
||||
"WEAVIATE_BATCH_SIZE",
|
||||
"WEAVIATE_GRPC_ENABLED",
|
||||
}
|
||||
|
||||
BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF = {
|
||||
@ -86,7 +85,6 @@ BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF = {
|
||||
"VIKINGDB_CONNECTION_TIMEOUT",
|
||||
"VIKINGDB_SOCKET_TIMEOUT",
|
||||
"WEAVIATE_BATCH_SIZE",
|
||||
"WEAVIATE_GRPC_ENABLED",
|
||||
}
|
||||
|
||||
API_CONFIG_SET = set(dotenv_values(Path("api") / Path(".env.example")).keys())
|
||||
|
||||
@ -12,6 +12,11 @@ NEXT_PUBLIC_API_PREFIX=http://localhost:5001/console/api
|
||||
# console or api domain.
|
||||
# example: http://udify.app/api
|
||||
NEXT_PUBLIC_PUBLIC_API_PREFIX=http://localhost:5001/api
|
||||
# Dev-only Hono proxy targets. The frontend keeps requesting http://localhost:5001 directly.
|
||||
HONO_PROXY_HOST=127.0.0.1
|
||||
HONO_PROXY_PORT=5001
|
||||
HONO_CONSOLE_API_PROXY_TARGET=
|
||||
HONO_PUBLIC_API_PROXY_TARGET=
|
||||
# When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1.
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=
|
||||
|
||||
|
||||
@ -3,12 +3,10 @@ import { Avatar } from '../index'
|
||||
|
||||
describe('Avatar', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render img element when avatar URL is provided', () => {
|
||||
it('should keep the fallback visible when avatar URL is provided before image load', () => {
|
||||
render(<Avatar name="John Doe" avatar="https://example.com/avatar.jpg" />)
|
||||
|
||||
const img = screen.getByRole('img', { name: 'John Doe' })
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg')
|
||||
expect(screen.getByText('J')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render fallback with uppercase initial when avatar is null', () => {
|
||||
@ -18,10 +16,9 @@ describe('Avatar', () => {
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both image and fallback when avatar is provided', () => {
|
||||
it('should render the fallback when avatar is provided', () => {
|
||||
render(<Avatar name="John" avatar="https://example.com/avatar.jpg" />)
|
||||
|
||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
||||
expect(screen.getByText('J')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -90,7 +87,7 @@ describe('Avatar', () => {
|
||||
})
|
||||
|
||||
describe('onLoadingStatusChange', () => {
|
||||
it('should render image when avatar and onLoadingStatusChange are provided', () => {
|
||||
it('should render the fallback when avatar and onLoadingStatusChange are provided', () => {
|
||||
render(
|
||||
<Avatar
|
||||
name="John"
|
||||
@ -99,7 +96,7 @@ describe('Avatar', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
||||
expect(screen.getByText('J')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render image when avatar is null even with onLoadingStatusChange', () => {
|
||||
|
||||
@ -978,7 +978,7 @@ describe('ChatWrapper', () => {
|
||||
expect(screen.getByAltText('answer icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render question icon when user avatar is available', () => {
|
||||
it('should render question icon fallback when user avatar is available', () => {
|
||||
vi.mocked(useChatWithHistoryContext).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
initUserVariables: {
|
||||
@ -992,12 +992,11 @@ describe('ChatWrapper', () => {
|
||||
chatList: [{ id: 'q1', content: 'Question' }],
|
||||
} as unknown as ChatHookReturn)
|
||||
|
||||
const { container } = render(<ChatWrapper />)
|
||||
const avatar = container.querySelector('img[alt="John Doe"]')
|
||||
expect(avatar).toBeInTheDocument()
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.getByText('J')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use fallback values for nullable appData, appMeta and user name', () => {
|
||||
it('should use fallback values for nullable appData, appMeta and avatar name', () => {
|
||||
vi.mocked(useChatWithHistoryContext).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
appData: null as unknown as AppData,
|
||||
@ -1014,7 +1013,7 @@ describe('ChatWrapper', () => {
|
||||
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.getByText('Question with fallback avatar name')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('user')).toBeInTheDocument()
|
||||
expect(screen.getByText('U')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set handleStop on currentChatInstanceRef', () => {
|
||||
|
||||
@ -327,7 +327,7 @@ describe('EmbeddedChatbot chat-wrapper', () => {
|
||||
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show the user name when avatar data is provided', () => {
|
||||
it('should show the user avatar fallback when avatar data is provided', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
initUserVariables: {
|
||||
avatar_url: 'https://example.com/avatar.png',
|
||||
@ -337,7 +337,7 @@ describe('EmbeddedChatbot chat-wrapper', () => {
|
||||
|
||||
render(<ChatWrapper />)
|
||||
|
||||
expect(screen.getByRole('img', { name: 'Alice' })).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -639,128 +639,50 @@ describe('Mermaid Flowchart Component Module Isolation', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('should tolerate missing hidden container during classic render and cleanup', async () => {
|
||||
vi.resetModules()
|
||||
let pendingContainerRef: unknown | null = null
|
||||
let patchedContainerRef = false
|
||||
let patchedTimeoutRef = false
|
||||
let containerReadCount = 0
|
||||
const virtualContainer = { innerHTML: 'seed' } as HTMLDivElement
|
||||
|
||||
vi.doMock('react', async () => {
|
||||
const reactActual = await vi.importActual<typeof import('react')>('react')
|
||||
const mockedUseRef = ((initialValue: unknown) => {
|
||||
const ref = reactActual.useRef(initialValue as never)
|
||||
if (!patchedContainerRef && initialValue === null)
|
||||
pendingContainerRef = ref
|
||||
|
||||
if (!patchedContainerRef
|
||||
&& pendingContainerRef
|
||||
&& typeof initialValue === 'string'
|
||||
&& initialValue.startsWith('mermaid-chart-')) {
|
||||
Object.defineProperty(pendingContainerRef as { current: unknown }, 'current', {
|
||||
configurable: true,
|
||||
get() {
|
||||
containerReadCount += 1
|
||||
if (containerReadCount === 1)
|
||||
return virtualContainer
|
||||
return null
|
||||
},
|
||||
set(_value: HTMLDivElement | null) { },
|
||||
})
|
||||
patchedContainerRef = true
|
||||
pendingContainerRef = null
|
||||
}
|
||||
|
||||
if (patchedContainerRef && !patchedTimeoutRef && initialValue === undefined) {
|
||||
patchedTimeoutRef = true
|
||||
Object.defineProperty(ref, 'current', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return undefined
|
||||
},
|
||||
set(_value: NodeJS.Timeout | undefined) { },
|
||||
})
|
||||
return ref
|
||||
}
|
||||
|
||||
return ref
|
||||
}) as typeof reactActual.useRef
|
||||
|
||||
return {
|
||||
...reactActual,
|
||||
useRef: mockedUseRef,
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { default: FlowchartFresh } = await import('../index')
|
||||
const { unmount } = render(<FlowchartFresh PrimitiveCode={mockCode} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-svg')).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
unmount()
|
||||
}
|
||||
finally {
|
||||
vi.doUnmock('react')
|
||||
}
|
||||
})
|
||||
|
||||
it('should tolerate missing hidden container during handDrawn render', async () => {
|
||||
vi.resetModules()
|
||||
let pendingContainerRef: unknown | null = null
|
||||
let patchedContainerRef = false
|
||||
let containerReadCount = 0
|
||||
const virtualContainer = { innerHTML: 'seed' } as HTMLDivElement
|
||||
|
||||
vi.doMock('react', async () => {
|
||||
const reactActual = await vi.importActual<typeof import('react')>('react')
|
||||
const mockedUseRef = ((initialValue: unknown) => {
|
||||
const ref = reactActual.useRef(initialValue as never)
|
||||
if (!patchedContainerRef && initialValue === null)
|
||||
pendingContainerRef = ref
|
||||
|
||||
if (!patchedContainerRef
|
||||
&& pendingContainerRef
|
||||
&& typeof initialValue === 'string'
|
||||
&& initialValue.startsWith('mermaid-chart-')) {
|
||||
Object.defineProperty(pendingContainerRef as { current: unknown }, 'current', {
|
||||
configurable: true,
|
||||
get() {
|
||||
containerReadCount += 1
|
||||
if (containerReadCount === 1)
|
||||
return virtualContainer
|
||||
return null
|
||||
},
|
||||
set(_value: HTMLDivElement | null) { },
|
||||
})
|
||||
patchedContainerRef = true
|
||||
pendingContainerRef = null
|
||||
}
|
||||
return ref
|
||||
}) as typeof reactActual.useRef
|
||||
|
||||
return {
|
||||
...reactActual,
|
||||
useRef: mockedUseRef,
|
||||
}
|
||||
})
|
||||
it('should cancel a pending classic render on unmount', async () => {
|
||||
const { default: FlowchartFresh } = await import('../index')
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { default: FlowchartFresh } = await import('../index')
|
||||
const { rerender } = render(<FlowchartFresh PrimitiveCode="graph" />)
|
||||
const { unmount } = render(<FlowchartFresh PrimitiveCode={mockCode} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText(HAND_DRAWN_RE))
|
||||
rerender(<FlowchartFresh PrimitiveCode={mockCode} />)
|
||||
unmount()
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(screen.getByText('test-svg-api')).toBeInTheDocument()
|
||||
|
||||
expect(vi.mocked(mermaidFresh.render)).not.toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('should cancel a pending handDrawn render on unmount', async () => {
|
||||
const { default: FlowchartFresh } = await import('../index')
|
||||
const { unmount } = render(<FlowchartFresh PrimitiveCode={mockCode} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-svg')).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
|
||||
const initialHandDrawnCalls = vi.mocked(mermaidFresh.mermaidAPI.render).mock.calls.length
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText(HAND_DRAWN_RE))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
unmount()
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
})
|
||||
|
||||
expect(vi.mocked(mermaidFresh.mermaidAPI.render).mock.calls.length).toBe(initialHandDrawnCalls)
|
||||
}
|
||||
finally {
|
||||
vi.useRealTimers()
|
||||
vi.doUnmock('react')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,7 +4,6 @@ import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Divider from '../divider'
|
||||
import './index.css'
|
||||
|
||||
type SegmentedControlOption<T> = {
|
||||
value: T
|
||||
@ -131,7 +130,7 @@ export const SegmentedControl = <T extends string | number | symbol>({
|
||||
<div className={cn('inline-flex items-center gap-x-1', ItemTextWrapperVariants({ size }))}>
|
||||
<span>{text}</span>
|
||||
{!!(count && size === 'large') && (
|
||||
<div className="system-2xs-medium-uppercase inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] text-text-tertiary">
|
||||
<div className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] text-text-tertiary system-2xs-medium-uppercase">
|
||||
{count}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -0,0 +1,396 @@
|
||||
import type { EdgeChange, ReactFlowProps } from 'reactflow'
|
||||
import type { Edge, Node } from '../types'
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
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: {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
}
|
||||
}
|
||||
|
||||
const eventEmitterState = vi.hoisted(() => ({
|
||||
subscription: null as null | ((payload: WorkflowUpdateEvent) => void),
|
||||
}))
|
||||
|
||||
const workflowHookMocks = vi.hoisted(() => ({
|
||||
handleNodeDragStart: vi.fn(),
|
||||
handleNodeDrag: vi.fn(),
|
||||
handleNodeDragStop: vi.fn(),
|
||||
handleNodeEnter: vi.fn(),
|
||||
handleNodeLeave: vi.fn(),
|
||||
handleNodeClick: vi.fn(),
|
||||
handleNodeConnect: vi.fn(),
|
||||
handleNodeConnectStart: vi.fn(),
|
||||
handleNodeConnectEnd: vi.fn(),
|
||||
handleNodeContextMenu: vi.fn(),
|
||||
handleHistoryBack: vi.fn(),
|
||||
handleHistoryForward: vi.fn(),
|
||||
handleEdgeEnter: vi.fn(),
|
||||
handleEdgeLeave: vi.fn(),
|
||||
handleEdgesChange: vi.fn(),
|
||||
handleEdgeContextMenu: vi.fn(),
|
||||
handleSelectionStart: vi.fn(),
|
||||
handleSelectionChange: vi.fn(),
|
||||
handleSelectionDrag: vi.fn(),
|
||||
handleSelectionContextMenu: vi.fn(),
|
||||
handlePaneContextMenu: vi.fn(),
|
||||
handleSyncWorkflowDraft: vi.fn(),
|
||||
fetchInspectVars: vi.fn(),
|
||||
isValidConnection: vi.fn(),
|
||||
useShortcuts: vi.fn(),
|
||||
useWorkflowSearch: vi.fn(),
|
||||
}))
|
||||
|
||||
const baseNodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
},
|
||||
] as unknown as Node[]
|
||||
|
||||
const baseEdges = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
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', () => ({
|
||||
default: () => () => null,
|
||||
}))
|
||||
|
||||
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: {
|
||||
useSubscription: (handler: (payload: WorkflowUpdateEvent) => void) => {
|
||||
eventEmitterState.subscription = handler
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllBuiltInTools: () => ({ data: [] }),
|
||||
useAllCustomTools: () => ({ data: [] }),
|
||||
useAllMCPTools: () => ({ data: [] }),
|
||||
useAllWorkflowTools: () => ({ data: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchAllInspectVars: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('../candidate-node', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../custom-connection-line', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../custom-edge', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../help-line', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../edge-contextmenu', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../node-contextmenu', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../nodes', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../nodes/data-source-empty', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../nodes/iteration-start', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../nodes/loop-start', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../note-node', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../operator', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../operator/control', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../panel-contextmenu', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../selection-contextmenu', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../simple-node', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../syncing-data-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useEdgesInteractions: () => ({
|
||||
handleEdgeEnter: workflowHookMocks.handleEdgeEnter,
|
||||
handleEdgeLeave: workflowHookMocks.handleEdgeLeave,
|
||||
handleEdgesChange: workflowHookMocks.handleEdgesChange,
|
||||
handleEdgeContextMenu: workflowHookMocks.handleEdgeContextMenu,
|
||||
}),
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeDragStart: workflowHookMocks.handleNodeDragStart,
|
||||
handleNodeDrag: workflowHookMocks.handleNodeDrag,
|
||||
handleNodeDragStop: workflowHookMocks.handleNodeDragStop,
|
||||
handleNodeEnter: workflowHookMocks.handleNodeEnter,
|
||||
handleNodeLeave: workflowHookMocks.handleNodeLeave,
|
||||
handleNodeClick: workflowHookMocks.handleNodeClick,
|
||||
handleNodeConnect: workflowHookMocks.handleNodeConnect,
|
||||
handleNodeConnectStart: workflowHookMocks.handleNodeConnectStart,
|
||||
handleNodeConnectEnd: workflowHookMocks.handleNodeConnectEnd,
|
||||
handleNodeContextMenu: workflowHookMocks.handleNodeContextMenu,
|
||||
handleHistoryBack: workflowHookMocks.handleHistoryBack,
|
||||
handleHistoryForward: workflowHookMocks.handleHistoryForward,
|
||||
}),
|
||||
useNodesReadOnly: () => ({
|
||||
nodesReadOnly: false,
|
||||
getNodesReadOnly: () => false,
|
||||
}),
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: workflowHookMocks.handleSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
}),
|
||||
usePanelInteractions: () => ({
|
||||
handlePaneContextMenu: workflowHookMocks.handlePaneContextMenu,
|
||||
handleEdgeContextmenuCancel: vi.fn(),
|
||||
}),
|
||||
useSelectionInteractions: () => ({
|
||||
handleSelectionStart: workflowHookMocks.handleSelectionStart,
|
||||
handleSelectionChange: workflowHookMocks.handleSelectionChange,
|
||||
handleSelectionDrag: workflowHookMocks.handleSelectionDrag,
|
||||
handleSelectionContextMenu: workflowHookMocks.handleSelectionContextMenu,
|
||||
}),
|
||||
useSetWorkflowVarsWithValue: () => ({
|
||||
fetchInspectVars: workflowHookMocks.fetchInspectVars,
|
||||
}),
|
||||
useShortcuts: workflowHookMocks.useShortcuts,
|
||||
useWorkflow: () => ({
|
||||
isValidConnection: workflowHookMocks.isValidConnection,
|
||||
}),
|
||||
useWorkflowReadOnly: () => ({
|
||||
workflowReadOnly: false,
|
||||
}),
|
||||
useWorkflowRefreshDraft: () => ({
|
||||
handleRefreshWorkflowDraft: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-workflow-search', () => ({
|
||||
useWorkflowSearch: workflowHookMocks.useWorkflowSearch,
|
||||
}))
|
||||
|
||||
vi.mock('../nodes/_base/components/variable/use-match-schema-type', () => ({
|
||||
default: () => ({
|
||||
schemaTypeDefinitions: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../workflow-history-store', () => ({
|
||||
WorkflowHistoryProvider: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
}))
|
||||
|
||||
function renderSubject() {
|
||||
return renderWorkflowComponent(
|
||||
<Workflow
|
||||
nodes={baseNodes}
|
||||
edges={baseEdges}
|
||||
/>,
|
||||
{
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'flow-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
describe('Workflow edge event wiring', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reactFlowState.lastProps = null
|
||||
eventEmitterState.subscription = null
|
||||
})
|
||||
|
||||
it('should forward React Flow edge events to workflow handlers when emitted by the canvas', () => {
|
||||
renderSubject()
|
||||
|
||||
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' }))
|
||||
|
||||
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,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should keep edge deletion delegated to workflow shortcuts instead of React Flow defaults', () => {
|
||||
renderSubject()
|
||||
|
||||
expect(reactFlowState.lastProps?.deleteKeyCode).toBeNull()
|
||||
})
|
||||
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
act(() => {
|
||||
eventEmitterState.subscription?.({
|
||||
type: WORKFLOW_DATA_UPDATE,
|
||||
payload: {
|
||||
nodes: baseNodes,
|
||||
edges: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
})
|
||||
})
|
||||
340
web/app/components/workflow/edge-contextmenu.spec.tsx
Normal file
340
web/app/components/workflow/edge-contextmenu.spec.tsx
Normal file
@ -0,0 +1,340 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect } from 'react'
|
||||
import { resetReactFlowMockState, rfState } from './__tests__/reactflow-mock-state'
|
||||
import { renderWorkflowComponent } from './__tests__/workflow-test-env'
|
||||
import EdgeContextmenu from './edge-contextmenu'
|
||||
import { useEdgesInteractions } from './hooks/use-edges-interactions'
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('./__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
describe('EdgeContextmenu', () => {
|
||||
const hooksStoreProps = {
|
||||
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
type TestNode = typeof rfState.nodes[number] & {
|
||||
selected?: boolean
|
||||
data: {
|
||||
selected?: boolean
|
||||
_isBundled?: boolean
|
||||
}
|
||||
}
|
||||
type TestEdge = typeof rfState.edges[number] & {
|
||||
selected?: boolean
|
||||
}
|
||||
const createNode = (id: string, selected = false): TestNode => ({
|
||||
id,
|
||||
position: { x: 0, y: 0 },
|
||||
data: { selected },
|
||||
selected,
|
||||
})
|
||||
const createEdge = (id: string, selected = false): TestEdge => ({
|
||||
id,
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
data: {},
|
||||
selected,
|
||||
})
|
||||
|
||||
const EdgeMenuHarness = () => {
|
||||
const { handleEdgeContextMenu, handleEdgeDelete } = useEdgesInteractions()
|
||||
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Right-click edge e1"
|
||||
onContextMenu={e => handleEdgeContextMenu(e as never, rfState.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, rfState.edges.find(edge => edge.id === 'e2') as never)}
|
||||
>
|
||||
edge-e2
|
||||
</button>
|
||||
<EdgeContextmenu />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetReactFlowMockState()
|
||||
rfState.nodes = [
|
||||
createNode('n1'),
|
||||
createNode('n2'),
|
||||
]
|
||||
rfState.edges = [
|
||||
createEdge('e1', true) as typeof rfState.edges[number] & { selected: boolean },
|
||||
createEdge('e2'),
|
||||
]
|
||||
rfState.setNodes.mockImplementation((nextNodes) => {
|
||||
rfState.nodes = nextNodes as typeof rfState.nodes
|
||||
})
|
||||
rfState.setEdges.mockImplementation((nextEdges) => {
|
||||
rfState.edges = nextEdges as typeof rfState.edges
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render when edgeMenu is absent', () => {
|
||||
renderWorkflowComponent(<EdgeContextmenu />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
|
||||
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()
|
||||
;(rfState.edges[0] as Record<string, unknown>).selected = true
|
||||
;(rfState.edges[1] as Record<string, unknown>).selected = false
|
||||
|
||||
const { store } = renderWorkflowComponent(<EdgeContextmenu />, {
|
||||
initialStoreState: {
|
||||
edgeMenu: {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
edgeId: 'e2',
|
||||
},
|
||||
},
|
||||
hooksStoreProps,
|
||||
})
|
||||
|
||||
const deleteAction = await screen.findByRole('menuitem', { name: /common:operation\.delete/i })
|
||||
expect(screen.getByText(/^del$/i)).toBeInTheDocument()
|
||||
|
||||
await user.click(deleteAction)
|
||||
|
||||
const updatedEdges = rfState.setEdges.mock.calls.at(-1)?.[0]
|
||||
expect(updatedEdges).toHaveLength(1)
|
||||
expect(updatedEdges[0].id).toBe('e1')
|
||||
expect(updatedEdges[0].selected).toBe(true)
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render the menu when the referenced edge no longer exists', () => {
|
||||
renderWorkflowComponent(<EdgeContextmenu />, {
|
||||
initialStoreState: {
|
||||
edgeMenu: {
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
edgeId: 'missing-edge',
|
||||
},
|
||||
},
|
||||
hooksStoreProps,
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the edge menu at the right-click position', async () => {
|
||||
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
|
||||
|
||||
renderWorkflowComponent(<EdgeMenuHarness />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
renderWorkflowComponent(<EdgeMenuHarness />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
|
||||
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(rfState.edges.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) => {
|
||||
renderWorkflowComponent(<EdgeMenuHarness />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
rfState.nodes = [createNode('n1', true), createNode('n2')]
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), {
|
||||
clientX: 240,
|
||||
clientY: 120,
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(rfState.edges.map(edge => edge.id)).toEqual(['e1'])
|
||||
expect(rfState.nodes.map(node => node.id)).toEqual(['n1', 'n2'])
|
||||
expect((rfState.nodes as TestNode[]).every(node => !node.selected && !node.data.selected)).toBe(true)
|
||||
})
|
||||
|
||||
it('should keep bundled multi-selection nodes intact when delete runs after right-clicking an edge', async () => {
|
||||
renderWorkflowComponent(<EdgeMenuHarness />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
rfState.nodes = [
|
||||
{ ...createNode('n1', true), data: { selected: true, _isBundled: true } },
|
||||
{ ...createNode('n2', 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, { key: 'Delete' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(rfState.edges.map(edge => edge.id)).toEqual(['e2'])
|
||||
expect(rfState.nodes).toHaveLength(2)
|
||||
expect((rfState.nodes as TestNode[]).every(node => !node.selected && !node.data.selected && !node.data._isBundled)).toBe(true)
|
||||
})
|
||||
|
||||
it('should retarget the menu and selected edge when right-clicking a different edge', async () => {
|
||||
const fromRectSpy = vi.spyOn(DOMRect, 'fromRect')
|
||||
|
||||
renderWorkflowComponent(<EdgeMenuHarness />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
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((rfState.edges as TestEdge[]).find(edge => edge.id === 'e1')?.selected).toBe(false)
|
||||
expect((rfState.edges as TestEdge[]).find(edge => edge.id === 'e2')?.selected).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide the menu when the target edge disappears after opening it', async () => {
|
||||
const { store } = renderWorkflowComponent(<EdgeMenuHarness />, {
|
||||
hooksStoreProps,
|
||||
})
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e1' }), {
|
||||
clientX: 160,
|
||||
clientY: 100,
|
||||
})
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
|
||||
rfState.edges = [createEdge('e2')]
|
||||
store.setState({
|
||||
edgeMenu: {
|
||||
clientX: 160,
|
||||
clientY: 100,
|
||||
edgeId: 'e1',
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
62
web/app/components/workflow/edge-contextmenu.tsx
Normal file
62
web/app/components/workflow/edge-contextmenu.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEdges } from 'reactflow'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
} from '@/app/components/base/ui/context-menu'
|
||||
import { useEdgesInteractions, usePanelInteractions } from './hooks'
|
||||
import ShortcutsName from './shortcuts-name'
|
||||
import { useStore } from './store'
|
||||
|
||||
const EdgeContextmenu = () => {
|
||||
const { t } = useTranslation()
|
||||
const edgeMenu = useStore(s => s.edgeMenu)
|
||||
const { handleEdgeDeleteById } = useEdgesInteractions()
|
||||
const { handleEdgeContextmenuCancel } = usePanelInteractions()
|
||||
const edges = useEdges()
|
||||
const currentEdgeExists = !edgeMenu || edges.some(edge => edge.id === edgeMenu.edgeId)
|
||||
|
||||
const anchor = useMemo(() => {
|
||||
if (!edgeMenu || !currentEdgeExists)
|
||||
return null
|
||||
|
||||
return {
|
||||
getBoundingClientRect: () => DOMRect.fromRect({
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: edgeMenu.clientX,
|
||||
y: edgeMenu.clientY,
|
||||
}),
|
||||
}
|
||||
}, [currentEdgeExists, edgeMenu])
|
||||
|
||||
if (!edgeMenu || !currentEdgeExists || !anchor)
|
||||
return null
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
open={!!edgeMenu}
|
||||
onOpenChange={open => !open && handleEdgeContextmenuCancel()}
|
||||
>
|
||||
<ContextMenuContent
|
||||
positionerProps={{ anchor }}
|
||||
popupClassName="rounded-lg"
|
||||
>
|
||||
<ContextMenuItem
|
||||
className="justify-between gap-4 px-3 text-text-secondary data-[highlighted]:bg-state-destructive-hover data-[highlighted]:text-text-destructive"
|
||||
onClick={() => handleEdgeDeleteById(edgeMenu.edgeId)}
|
||||
>
|
||||
<span>{t('common:operation.delete')}</span>
|
||||
<ShortcutsName keys={['del']} />
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(EdgeContextmenu)
|
||||
@ -83,15 +83,56 @@ describe('useEdgesInteractions', () => {
|
||||
expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(false)
|
||||
})
|
||||
|
||||
it('handleEdgeContextMenu should select the clicked edge and open edgeMenu', () => {
|
||||
const preventDefault = vi.fn()
|
||||
const { result, store } = renderEdgesInteractions()
|
||||
rfState.nodes = [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: { selected: true, _isBundled: true }, selected: true } as typeof rfState.nodes[number] & { selected: boolean },
|
||||
{ id: 'n2', position: { x: 100, y: 0 }, data: { _isBundled: true } },
|
||||
]
|
||||
rfState.edges = [
|
||||
{ id: 'e1', source: 'n1', target: 'n2', sourceHandle: 'branch-a', data: { _hovering: false, _isBundled: true } },
|
||||
{ id: 'e2', source: 'n1', target: 'n2', sourceHandle: 'branch-b', data: { _hovering: false, _isBundled: true } },
|
||||
]
|
||||
|
||||
result.current.handleEdgeContextMenu({
|
||||
preventDefault,
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
} as never, rfState.edges[1] as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
|
||||
const updated = rfState.setEdges.mock.calls[0][0]
|
||||
expect(updated.find((e: { id: string }) => e.id === 'e1').selected).toBe(false)
|
||||
expect(updated.find((e: { id: string }) => e.id === 'e2').selected).toBe(true)
|
||||
expect(updated.every((e: { data: { _isBundled?: boolean } }) => !e.data._isBundled)).toBe(true)
|
||||
const updatedNodes = rfState.setNodes.mock.calls[0][0]
|
||||
expect(updatedNodes.every((node: { data: { selected?: boolean, _isBundled?: boolean }, selected?: boolean }) => !node.data.selected && !node.selected && !node.data._isBundled)).toBe(true)
|
||||
|
||||
expect(store.getState().edgeMenu).toEqual({
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
edgeId: 'e2',
|
||||
})
|
||||
expect(store.getState().nodeMenu).toBeUndefined()
|
||||
expect(store.getState().panelMenu).toBeUndefined()
|
||||
expect(store.getState().selectionMenu).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handleEdgeDelete should remove selected edge and trigger sync + history', () => {
|
||||
;(rfState.edges[0] as Record<string, unknown>).selected = true
|
||||
const { result } = renderEdgesInteractions()
|
||||
const { result, store } = renderEdgesInteractions()
|
||||
store.setState({
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||
})
|
||||
|
||||
result.current.handleEdgeDelete()
|
||||
|
||||
const updated = rfState.setEdges.mock.calls[0][0]
|
||||
expect(updated).toHaveLength(1)
|
||||
expect(updated[0].id).toBe('e2')
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
|
||||
})
|
||||
|
||||
@ -101,13 +142,34 @@ describe('useEdgesInteractions', () => {
|
||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleEdgeDeleteById should remove the requested edge even when another edge is selected', () => {
|
||||
;(rfState.edges[0] as Record<string, unknown>).selected = true
|
||||
const { result, store } = renderEdgesInteractions()
|
||||
store.setState({
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e2' },
|
||||
})
|
||||
|
||||
result.current.handleEdgeDeleteById('e2')
|
||||
|
||||
const updated = rfState.setEdges.mock.calls[0][0]
|
||||
expect(updated).toHaveLength(1)
|
||||
expect(updated[0].id).toBe('e1')
|
||||
expect(updated[0].selected).toBe(true)
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete')
|
||||
})
|
||||
|
||||
it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', () => {
|
||||
const { result } = renderEdgesInteractions()
|
||||
const { result, store } = renderEdgesInteractions()
|
||||
store.setState({
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||
})
|
||||
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
|
||||
|
||||
const updated = rfState.setEdges.mock.calls[0][0]
|
||||
expect(updated).toHaveLength(1)
|
||||
expect(updated[0].id).toBe('e2')
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDeleteByDeleteBranch')
|
||||
})
|
||||
|
||||
@ -142,6 +204,23 @@ describe('useEdgesInteractions', () => {
|
||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleEdgeDeleteById should do nothing', () => {
|
||||
const { result } = renderEdgesInteractions()
|
||||
result.current.handleEdgeDeleteById('e1')
|
||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleEdgeContextMenu should do nothing', () => {
|
||||
const { result, store } = renderEdgesInteractions()
|
||||
result.current.handleEdgeContextMenu({
|
||||
preventDefault: vi.fn(),
|
||||
clientX: 200,
|
||||
clientY: 120,
|
||||
} as never, rfState.edges[0] as never)
|
||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handleEdgeDeleteByDeleteBranch should do nothing', () => {
|
||||
const { result } = renderEdgesInteractions()
|
||||
result.current.handleEdgeDeleteByDeleteBranch('n1', 'branch-a')
|
||||
|
||||
@ -26,7 +26,13 @@ describe('usePanelInteractions', () => {
|
||||
})
|
||||
|
||||
it('handlePaneContextMenu should set panelMenu with computed coordinates when container exists', () => {
|
||||
const { result, store } = renderWorkflowHook(() => usePanelInteractions())
|
||||
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
|
||||
initialStoreState: {
|
||||
nodeMenu: { top: 20, left: 40, nodeId: 'n1' },
|
||||
selectionMenu: { top: 30, left: 50 },
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||
},
|
||||
})
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
result.current.handlePaneContextMenu({
|
||||
@ -40,6 +46,9 @@ describe('usePanelInteractions', () => {
|
||||
top: 200,
|
||||
left: 250,
|
||||
})
|
||||
expect(store.getState().nodeMenu).toBeUndefined()
|
||||
expect(store.getState().selectionMenu).toBeUndefined()
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handlePaneContextMenu should throw when container does not exist', () => {
|
||||
@ -75,4 +84,14 @@ describe('usePanelInteractions', () => {
|
||||
|
||||
expect(store.getState().nodeMenu).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handleEdgeContextmenuCancel should clear edgeMenu', () => {
|
||||
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
|
||||
initialStoreState: { edgeMenu: { clientX: 300, clientY: 200, edgeId: 'e1' } },
|
||||
})
|
||||
|
||||
result.current.handleEdgeContextmenuCancel()
|
||||
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -150,7 +150,13 @@ describe('useSelectionInteractions', () => {
|
||||
})
|
||||
|
||||
it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useSelectionInteractions())
|
||||
const { result, store } = renderWorkflowHook(() => useSelectionInteractions(), {
|
||||
initialStoreState: {
|
||||
nodeMenu: { top: 10, left: 20, nodeId: 'n1' },
|
||||
panelMenu: { top: 30, left: 40 },
|
||||
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
|
||||
},
|
||||
})
|
||||
|
||||
const wrongTarget = document.createElement('div')
|
||||
wrongTarget.classList.add('some-other-class')
|
||||
@ -176,6 +182,9 @@ describe('useSelectionInteractions', () => {
|
||||
top: 150,
|
||||
left: 200,
|
||||
})
|
||||
expect(store.getState().nodeMenu).toBeUndefined()
|
||||
expect(store.getState().panelMenu).toBeUndefined()
|
||||
expect(store.getState().edgeMenu).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handleSelectionContextmenuCancel should clear selectionMenu', () => {
|
||||
|
||||
@ -10,6 +10,7 @@ import { useCallback } from 'react'
|
||||
import {
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
import { useNodesReadOnly } from './use-workflow'
|
||||
@ -17,10 +18,52 @@ import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history
|
||||
|
||||
export const useEdgesInteractions = () => {
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
|
||||
const deleteEdgeById = useCallback((edgeId: string) => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
const currentEdgeIndex = edges.findIndex(edge => edge.id === edgeId)
|
||||
|
||||
if (currentEdgeIndex < 0)
|
||||
return
|
||||
const currentEdge = edges[currentEdgeIndex]
|
||||
const nodes = getNodes()
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[
|
||||
{ type: 'remove', edge: currentEdge },
|
||||
],
|
||||
nodes,
|
||||
)
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
draft.forEach((node) => {
|
||||
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
|
||||
node.data = {
|
||||
...node.data,
|
||||
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.splice(currentEdgeIndex, 1)
|
||||
})
|
||||
setEdges(newEdges)
|
||||
const currentEdgeMenu = workflowStore.getState().edgeMenu
|
||||
if (currentEdgeMenu?.edgeId === currentEdge.id)
|
||||
workflowStore.setState({ edgeMenu: undefined })
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeDelete)
|
||||
}, [store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
|
||||
const handleEdgeEnter = useCallback<EdgeMouseHandler>((_, edge) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
@ -88,50 +131,31 @@ export const useEdgesInteractions = () => {
|
||||
return draft.filter(edge => !edgeWillBeDeleted.find(e => e.id === edge.id))
|
||||
})
|
||||
setEdges(newEdges)
|
||||
const currentEdgeMenu = workflowStore.getState().edgeMenu
|
||||
if (currentEdgeMenu && edgeWillBeDeleted.some(edge => edge.id === currentEdgeMenu.edgeId))
|
||||
workflowStore.setState({ edgeMenu: undefined })
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch)
|
||||
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
}, [getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
|
||||
const handleEdgeDelete = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
const { edges } = store.getState()
|
||||
const currentEdge = edges.find(edge => edge.selected)
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
const currentEdgeIndex = edges.findIndex(edge => edge.selected)
|
||||
|
||||
if (currentEdgeIndex < 0)
|
||||
if (!currentEdge)
|
||||
return
|
||||
const currentEdge = edges[currentEdgeIndex]
|
||||
const nodes = getNodes()
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[
|
||||
{ type: 'remove', edge: currentEdge },
|
||||
],
|
||||
nodes,
|
||||
)
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
draft.forEach((node) => {
|
||||
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
|
||||
node.data = {
|
||||
...node.data,
|
||||
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.splice(currentEdgeIndex, 1)
|
||||
})
|
||||
setEdges(newEdges)
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeDelete)
|
||||
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
|
||||
deleteEdgeById(currentEdge.id)
|
||||
}, [deleteEdgeById, getNodesReadOnly, store])
|
||||
|
||||
const handleEdgeDeleteById = useCallback((edgeId: string) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
deleteEdgeById(edgeId)
|
||||
}, [deleteEdgeById, getNodesReadOnly])
|
||||
|
||||
const handleEdgesChange = useCallback<OnEdgesChange>((changes) => {
|
||||
if (getNodesReadOnly())
|
||||
@ -200,16 +224,61 @@ export const useEdgesInteractions = () => {
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
const currentEdgeMenu = workflowStore.getState().edgeMenu
|
||||
if (currentEdgeMenu && !newEdges.some(edge => edge.id === currentEdgeMenu.edgeId))
|
||||
workflowStore.setState({ edgeMenu: undefined })
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeSourceHandleChange)
|
||||
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
}, [getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
|
||||
const handleEdgeContextMenu = useCallback<EdgeMouseHandler>((e, edge) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.forEach((item) => {
|
||||
item.selected = item.id === edge.id
|
||||
if (item.data._isBundled)
|
||||
item.data._isBundled = false
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
const nodes = getNodes()
|
||||
if (nodes.some(node => node.data.selected || node.selected || node.data._isBundled)) {
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
draft.forEach((node) => {
|
||||
node.data.selected = false
|
||||
if (node.data._isBundled)
|
||||
node.data._isBundled = false
|
||||
node.selected = false
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
}
|
||||
|
||||
workflowStore.setState({
|
||||
nodeMenu: undefined,
|
||||
panelMenu: undefined,
|
||||
selectionMenu: undefined,
|
||||
edgeMenu: {
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
edgeId: edge.id,
|
||||
},
|
||||
})
|
||||
}, [store, workflowStore, getNodesReadOnly])
|
||||
|
||||
return {
|
||||
handleEdgeEnter,
|
||||
handleEdgeLeave,
|
||||
handleEdgeDeleteByDeleteBranch,
|
||||
handleEdgeDelete,
|
||||
handleEdgeDeleteById,
|
||||
handleEdgesChange,
|
||||
handleEdgeSourceHandleChange,
|
||||
handleEdgeContextMenu,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1640,6 +1640,9 @@ export const useNodesInteractions = () => {
|
||||
const container = document.querySelector('#workflow-container')
|
||||
const { x, y } = container!.getBoundingClientRect()
|
||||
workflowStore.setState({
|
||||
panelMenu: undefined,
|
||||
selectionMenu: undefined,
|
||||
edgeMenu: undefined,
|
||||
nodeMenu: {
|
||||
top: e.clientY - y,
|
||||
left: e.clientX - x,
|
||||
@ -2096,7 +2099,9 @@ export const useNodesInteractions = () => {
|
||||
|
||||
setEdges(edges)
|
||||
setNodes(nodes)
|
||||
workflowStore.setState({ edgeMenu: undefined })
|
||||
}, [
|
||||
workflowStore,
|
||||
store,
|
||||
undo,
|
||||
workflowHistoryStore,
|
||||
@ -2117,9 +2122,11 @@ export const useNodesInteractions = () => {
|
||||
|
||||
setEdges(edges)
|
||||
setNodes(nodes)
|
||||
workflowStore.setState({ edgeMenu: undefined })
|
||||
}, [
|
||||
redo,
|
||||
store,
|
||||
workflowStore,
|
||||
workflowHistoryStore,
|
||||
getNodesReadOnly,
|
||||
getWorkflowReadOnly,
|
||||
|
||||
@ -10,6 +10,9 @@ export const usePanelInteractions = () => {
|
||||
const container = document.querySelector('#workflow-container')
|
||||
const { x, y } = container!.getBoundingClientRect()
|
||||
workflowStore.setState({
|
||||
nodeMenu: undefined,
|
||||
selectionMenu: undefined,
|
||||
edgeMenu: undefined,
|
||||
panelMenu: {
|
||||
top: e.clientY - y,
|
||||
left: e.clientX - x,
|
||||
@ -29,9 +32,16 @@ export const usePanelInteractions = () => {
|
||||
})
|
||||
}, [workflowStore])
|
||||
|
||||
const handleEdgeContextmenuCancel = useCallback(() => {
|
||||
workflowStore.setState({
|
||||
edgeMenu: undefined,
|
||||
})
|
||||
}, [workflowStore])
|
||||
|
||||
return {
|
||||
handlePaneContextMenu,
|
||||
handlePaneContextmenuCancel,
|
||||
handleNodeContextmenuCancel,
|
||||
handleEdgeContextmenuCancel,
|
||||
}
|
||||
}
|
||||
|
||||
@ -140,6 +140,9 @@ export const useSelectionInteractions = () => {
|
||||
const container = document.querySelector('#workflow-container')
|
||||
const { x, y } = container!.getBoundingClientRect()
|
||||
workflowStore.setState({
|
||||
nodeMenu: undefined,
|
||||
panelMenu: undefined,
|
||||
edgeMenu: undefined,
|
||||
selectionMenu: {
|
||||
top: e.clientY - y,
|
||||
left: e.clientX - x,
|
||||
|
||||
@ -55,6 +55,7 @@ import {
|
||||
import CustomConnectionLine from './custom-connection-line'
|
||||
import CustomEdge from './custom-edge'
|
||||
import DatasetsDetailProvider from './datasets-detail-store/provider'
|
||||
import EdgeContextmenu from './edge-contextmenu'
|
||||
import HelpLine from './help-line'
|
||||
import {
|
||||
useEdgesInteractions,
|
||||
@ -203,6 +204,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
setNodes(v.payload.nodes)
|
||||
store.getState().setNodes(v.payload.nodes)
|
||||
setEdges(v.payload.edges)
|
||||
workflowStore.setState({ edgeMenu: undefined })
|
||||
|
||||
if (v.payload.viewport)
|
||||
reactflow.setViewport(v.payload.viewport)
|
||||
@ -306,6 +308,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
handleEdgeEnter,
|
||||
handleEdgeLeave,
|
||||
handleEdgesChange,
|
||||
handleEdgeContextMenu,
|
||||
} = useEdgesInteractions()
|
||||
const {
|
||||
handleSelectionStart,
|
||||
@ -401,6 +404,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
<Operator handleRedo={handleHistoryForward} handleUndo={handleHistoryBack} />
|
||||
<PanelContextmenu />
|
||||
<NodeContextmenu />
|
||||
<EdgeContextmenu />
|
||||
<SelectionContextmenu />
|
||||
<HelpLine />
|
||||
{
|
||||
@ -433,6 +437,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
onEdgeMouseEnter={handleEdgeEnter}
|
||||
onEdgeMouseLeave={handleEdgeLeave}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onEdgeContextMenu={handleEdgeContextMenu}
|
||||
onSelectionStart={handleSelectionStart}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onSelectionDrag={handleSelectionDrag}
|
||||
|
||||
@ -2,7 +2,6 @@ import type { Node } from './types'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
@ -13,13 +12,9 @@ import { useStore } from './store'
|
||||
const NodeContextmenu = () => {
|
||||
const ref = useRef(null)
|
||||
const nodes = useNodes()
|
||||
const { handleNodeContextmenuCancel, handlePaneContextmenuCancel } = usePanelInteractions()
|
||||
const { handleNodeContextmenuCancel } = usePanelInteractions()
|
||||
const nodeMenu = useStore(s => s.nodeMenu)
|
||||
const currentNode = nodes.find(node => node.id === nodeMenu?.nodeId) as Node
|
||||
useEffect(() => {
|
||||
if (nodeMenu)
|
||||
handlePaneContextmenuCancel()
|
||||
}, [nodeMenu, handlePaneContextmenuCancel])
|
||||
|
||||
useClickAway(() => {
|
||||
handleNodeContextmenuCancel()
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useClickAway } from 'ahooks'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -25,16 +24,11 @@ const PanelContextmenu = () => {
|
||||
const clipboardElements = useStore(s => s.clipboardElements)
|
||||
const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal)
|
||||
const { handleNodesPaste } = useNodesInteractions()
|
||||
const { handlePaneContextmenuCancel, handleNodeContextmenuCancel } = usePanelInteractions()
|
||||
const { handlePaneContextmenuCancel } = usePanelInteractions()
|
||||
const { handleStartWorkflowRun } = useWorkflowStartRun()
|
||||
const { handleAddNote } = useOperator()
|
||||
const { exportCheck } = useDSL()
|
||||
|
||||
useEffect(() => {
|
||||
if (panelMenu)
|
||||
handleNodeContextmenuCancel()
|
||||
}, [panelMenu, handleNodeContextmenuCancel])
|
||||
|
||||
useClickAway(() => {
|
||||
handlePaneContextmenuCancel()
|
||||
}, ref)
|
||||
|
||||
@ -97,6 +97,7 @@ describe('createWorkflowStore', () => {
|
||||
['showDebugAndPreviewPanel', 'setShowDebugAndPreviewPanel', true],
|
||||
['panelMenu', 'setPanelMenu', { top: 10, left: 20 }],
|
||||
['selectionMenu', 'setSelectionMenu', { top: 50, left: 60 }],
|
||||
['edgeMenu', 'setEdgeMenu', { clientX: 320, clientY: 180, edgeId: 'e1' }],
|
||||
['showVariableInspectPanel', 'setShowVariableInspectPanel', true],
|
||||
['initShowLastRunTab', 'setInitShowLastRunTab', true],
|
||||
])('should update %s', (stateKey, setter, value) => {
|
||||
|
||||
@ -20,6 +20,12 @@ export type PanelSliceShape = {
|
||||
left: number
|
||||
}
|
||||
setSelectionMenu: (selectionMenu: PanelSliceShape['selectionMenu']) => void
|
||||
edgeMenu?: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
edgeId: string
|
||||
}
|
||||
setEdgeMenu: (edgeMenu: PanelSliceShape['edgeMenu']) => void
|
||||
showVariableInspectPanel: boolean
|
||||
setShowVariableInspectPanel: (showVariableInspectPanel: boolean) => void
|
||||
initShowLastRunTab: boolean
|
||||
@ -40,6 +46,8 @@ export const createPanelSlice: StateCreator<PanelSliceShape> = set => ({
|
||||
setPanelMenu: panelMenu => set(() => ({ panelMenu })),
|
||||
selectionMenu: undefined,
|
||||
setSelectionMenu: selectionMenu => set(() => ({ selectionMenu })),
|
||||
edgeMenu: undefined,
|
||||
setEdgeMenu: edgeMenu => set(() => ({ edgeMenu })),
|
||||
showVariableInspectPanel: false,
|
||||
setShowVariableInspectPanel: showVariableInspectPanel => set(() => ({ showVariableInspectPanel })),
|
||||
initShowLastRunTab: false,
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
@import "../components/base/button/index.css";
|
||||
@import "../components/base/modal/index.css";
|
||||
@import "../components/base/premium-badge/index.css";
|
||||
@import "../components/base/segmented-control/index.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
||||
@ -2589,11 +2589,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/segmented-control/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/select/custom.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
|
||||
@ -7,9 +7,10 @@ const config: KnipConfig = {
|
||||
entry: [
|
||||
'scripts/**/*.{js,ts,mjs}',
|
||||
'bin/**/*.{js,ts,mjs}',
|
||||
'taze.config.js',
|
||||
'tsslint.config.ts',
|
||||
],
|
||||
ignore: [
|
||||
'i18n/**',
|
||||
'public/**',
|
||||
],
|
||||
ignoreBinaries: [
|
||||
@ -19,9 +20,6 @@ const config: KnipConfig = {
|
||||
'@iconify-json/*',
|
||||
|
||||
'@storybook/addon-onboarding',
|
||||
|
||||
'@tsslint/compat-eslint',
|
||||
'@tsslint/config',
|
||||
],
|
||||
rules: {
|
||||
files: 'warn',
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.32.0",
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"imports": {
|
||||
"#i18n": {
|
||||
"react-server": "./i18n-config/lib.server.ts",
|
||||
@ -32,6 +32,7 @@
|
||||
"build:vinext": "vinext build",
|
||||
"dev": "next dev",
|
||||
"dev:inspect": "next dev --inspect",
|
||||
"dev:proxy": "tsx ./scripts/dev-hono-proxy.ts",
|
||||
"dev:vinext": "vinext dev",
|
||||
"gen-doc-paths": "tsx ./scripts/gen-doc-paths.ts",
|
||||
"gen-icons": "node ./scripts/gen-icons.mjs && eslint --fix app/components/base/icons/src/",
|
||||
@ -50,7 +51,6 @@
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run --coverage --silent=passed-only",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
@ -58,14 +58,15 @@
|
||||
"uglify-embed": "node ./bin/uglify-embed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-browser": "2.36.3",
|
||||
"@amplitude/plugin-session-replay-browser": "1.25.21",
|
||||
"@base-ui/react": "1.2.0",
|
||||
"@amplitude/analytics-browser": "2.36.4",
|
||||
"@amplitude/plugin-session-replay-browser": "1.26.1",
|
||||
"@base-ui/react": "1.3.0",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@floating-ui/react": "0.27.19",
|
||||
"@formatjs/intl-localematcher": "0.8.1",
|
||||
"@headlessui/react": "2.2.9",
|
||||
"@heroicons/react": "2.2.0",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@lexical/code": "0.41.0",
|
||||
"@lexical/link": "0.41.0",
|
||||
"@lexical/list": "0.41.0",
|
||||
@ -85,7 +86,7 @@
|
||||
"@svgdotjs/svg.js": "3.2.5",
|
||||
"@t3-oss/env-nextjs": "0.13.10",
|
||||
"@tailwindcss/typography": "0.5.19",
|
||||
"@tanstack/react-form": "1.28.4",
|
||||
"@tanstack/react-form": "1.28.5",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"abcjs": "6.6.2",
|
||||
"ahooks": "3.9.6",
|
||||
@ -94,9 +95,9 @@
|
||||
"cmdk": "1.1.1",
|
||||
"copy-to-clipboard": "3.3.3",
|
||||
"cron-parser": "5.5.0",
|
||||
"dayjs": "1.11.19",
|
||||
"dayjs": "1.11.20",
|
||||
"decimal.js": "10.6.0",
|
||||
"dompurify": "3.3.2",
|
||||
"dompurify": "3.3.3",
|
||||
"echarts": "6.0.0",
|
||||
"echarts-for-react": "3.0.6",
|
||||
"elkjs": "0.11.1",
|
||||
@ -106,9 +107,10 @@
|
||||
"es-toolkit": "1.45.1",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"foxact": "0.2.54",
|
||||
"hono": "4.12.7",
|
||||
"html-entities": "2.6.0",
|
||||
"html-to-image": "1.11.13",
|
||||
"i18next": "25.8.17",
|
||||
"i18next": "25.8.18",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"immer": "11.1.4",
|
||||
"jotai": "2.18.1",
|
||||
@ -136,7 +138,7 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-easy-crop": "5.5.6",
|
||||
"react-hotkeys-hook": "5.2.4",
|
||||
"react-i18next": "16.5.6",
|
||||
"react-i18next": "16.5.8",
|
||||
"react-multi-email": "1.0.25",
|
||||
"react-papaparse": "4.4.0",
|
||||
"react-pdf-highlighter": "8.0.0-rc.0",
|
||||
@ -164,7 +166,7 @@
|
||||
"zustand": "5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "7.7.0",
|
||||
"@antfu/eslint-config": "7.7.2",
|
||||
"@chromatic-com/storybook": "5.0.1",
|
||||
"@egoist/tailwindcss-icons": "1.9.2",
|
||||
"@eslint-react/eslint-plugin": "2.13.0",
|
||||
@ -183,8 +185,8 @@
|
||||
"@storybook/nextjs-vite": "10.2.17",
|
||||
"@storybook/react": "10.2.17",
|
||||
"@tanstack/eslint-plugin-query": "5.91.4",
|
||||
"@tanstack/react-devtools": "0.9.10",
|
||||
"@tanstack/react-form-devtools": "0.2.17",
|
||||
"@tanstack/react-devtools": "0.9.13",
|
||||
"@tanstack/react-form-devtools": "0.2.18",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
@ -196,7 +198,7 @@
|
||||
"@types/js-cookie": "3.0.6",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/negotiator": "0.6.4",
|
||||
"@types/node": "25.4.0",
|
||||
"@types/node": "25.5.0",
|
||||
"@types/postcss-js": "4.1.0",
|
||||
"@types/qs": "6.15.0",
|
||||
"@types/react": "19.2.14",
|
||||
@ -207,10 +209,10 @@
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260310.1",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260312.1",
|
||||
"@vitejs/plugin-react": "6.0.0",
|
||||
"@vitejs/plugin-rsc": "0.5.21",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"@vitest/coverage-v8": "4.1.0",
|
||||
"agentation": "2.3.2",
|
||||
"autoprefixer": "10.4.27",
|
||||
"code-inspector-plugin": "1.4.4",
|
||||
@ -231,17 +233,17 @@
|
||||
"postcss": "8.5.8",
|
||||
"postcss-js": "5.1.0",
|
||||
"react-server-dom-webpack": "19.2.4",
|
||||
"sass": "1.97.3",
|
||||
"sass": "1.98.0",
|
||||
"storybook": "10.2.17",
|
||||
"tailwindcss": "3.4.19",
|
||||
"taze": "19.10.0",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"uglify-js": "3.19.3",
|
||||
"vinext": "0.0.29",
|
||||
"vite": "8.0.0-beta.18",
|
||||
"vinext": "https://pkg.pr.new/vinext@18fe3ea",
|
||||
"vite": "8.0.0",
|
||||
"vite-plugin-inspect": "11.3.3",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.0.18",
|
||||
"vitest": "4.1.0",
|
||||
"vitest-canvas-mock": "1.1.3"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
98
web/plugins/dev-proxy/cookies.ts
Normal file
98
web/plugins/dev-proxy/cookies.ts
Normal file
@ -0,0 +1,98 @@
|
||||
const DEFAULT_PROXY_TARGET = 'https://cloud.dify.ai'
|
||||
|
||||
const SECURE_COOKIE_PREFIX_PATTERN = /^__(Host|Secure)-/
|
||||
const SAME_SITE_NONE_PATTERN = /^samesite=none$/i
|
||||
const COOKIE_PATH_PATTERN = /^path=/i
|
||||
const COOKIE_DOMAIN_PATTERN = /^domain=/i
|
||||
const COOKIE_SECURE_PATTERN = /^secure$/i
|
||||
const COOKIE_PARTITIONED_PATTERN = /^partitioned$/i
|
||||
|
||||
const HOST_PREFIX_COOKIE_NAMES = new Set([
|
||||
'access_token',
|
||||
'csrf_token',
|
||||
'refresh_token',
|
||||
'webapp_access_token',
|
||||
])
|
||||
|
||||
const isPassportCookie = (cookieName: string) => cookieName.startsWith('passport-')
|
||||
|
||||
const shouldUseHostPrefix = (cookieName: string) => {
|
||||
const normalizedCookieName = cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
|
||||
return HOST_PREFIX_COOKIE_NAMES.has(normalizedCookieName) || isPassportCookie(normalizedCookieName)
|
||||
}
|
||||
|
||||
const toUpstreamCookieName = (cookieName: string) => {
|
||||
if (cookieName.startsWith('__Host-'))
|
||||
return cookieName
|
||||
|
||||
if (cookieName.startsWith('__Secure-'))
|
||||
return `__Host-${cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')}`
|
||||
|
||||
if (!shouldUseHostPrefix(cookieName))
|
||||
return cookieName
|
||||
|
||||
return `__Host-${cookieName}`
|
||||
}
|
||||
|
||||
const toLocalCookieName = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
|
||||
|
||||
export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => {
|
||||
if (!cookieHeader)
|
||||
return cookieHeader
|
||||
|
||||
return cookieHeader
|
||||
.split(/;\s*/)
|
||||
.filter(Boolean)
|
||||
.map((cookie) => {
|
||||
const separatorIndex = cookie.indexOf('=')
|
||||
if (separatorIndex === -1)
|
||||
return cookie
|
||||
|
||||
const cookieName = cookie.slice(0, separatorIndex).trim()
|
||||
const cookieValue = cookie.slice(separatorIndex + 1)
|
||||
return `${toUpstreamCookieName(cookieName)}=${cookieValue}`
|
||||
})
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
const rewriteSetCookieValueForLocal = (setCookieValue: string) => {
|
||||
const [rawCookiePair, ...rawAttributes] = setCookieValue.split(';')
|
||||
const separatorIndex = rawCookiePair.indexOf('=')
|
||||
|
||||
if (separatorIndex === -1)
|
||||
return setCookieValue
|
||||
|
||||
const cookieName = rawCookiePair.slice(0, separatorIndex).trim()
|
||||
const cookieValue = rawCookiePair.slice(separatorIndex + 1)
|
||||
const rewrittenAttributes = rawAttributes
|
||||
.map(attribute => attribute.trim())
|
||||
.filter(attribute =>
|
||||
!COOKIE_DOMAIN_PATTERN.test(attribute)
|
||||
&& !COOKIE_SECURE_PATTERN.test(attribute)
|
||||
&& !COOKIE_PARTITIONED_PATTERN.test(attribute),
|
||||
)
|
||||
.map((attribute) => {
|
||||
if (SAME_SITE_NONE_PATTERN.test(attribute))
|
||||
return 'SameSite=Lax'
|
||||
|
||||
if (COOKIE_PATH_PATTERN.test(attribute))
|
||||
return 'Path=/'
|
||||
|
||||
return attribute
|
||||
})
|
||||
|
||||
return [`${toLocalCookieName(cookieName)}=${cookieValue}`, ...rewrittenAttributes].join('; ')
|
||||
}
|
||||
|
||||
export const rewriteSetCookieHeadersForLocal = (setCookieHeaders?: string | string[]): string[] | undefined => {
|
||||
if (!setCookieHeaders)
|
||||
return undefined
|
||||
|
||||
const normalizedHeaders = Array.isArray(setCookieHeaders)
|
||||
? setCookieHeaders
|
||||
: [setCookieHeaders]
|
||||
|
||||
return normalizedHeaders.map(rewriteSetCookieValueForLocal)
|
||||
}
|
||||
|
||||
export { DEFAULT_PROXY_TARGET }
|
||||
113
web/plugins/dev-proxy/server.spec.ts
Normal file
113
web/plugins/dev-proxy/server.spec.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin, resolveDevProxyTargets } from './server'
|
||||
|
||||
describe('dev proxy server', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Scenario: Hono proxy targets should be read directly from env.
|
||||
it('should resolve Hono proxy targets from env', () => {
|
||||
// Arrange
|
||||
const targets = resolveDevProxyTargets({
|
||||
HONO_CONSOLE_API_PROXY_TARGET: 'https://console.example.com',
|
||||
HONO_PUBLIC_API_PROXY_TARGET: 'https://public.example.com',
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(targets.consoleApiTarget).toBe('https://console.example.com')
|
||||
expect(targets.publicApiTarget).toBe('https://public.example.com')
|
||||
})
|
||||
|
||||
// Scenario: target paths should not be duplicated when the incoming route already includes them.
|
||||
it('should preserve prefixed targets when building upstream URLs', () => {
|
||||
// Act
|
||||
const url = buildUpstreamUrl('https://api.example.com/console/api', '/console/api/apps', '?page=1')
|
||||
|
||||
// Assert
|
||||
expect(url.href).toBe('https://api.example.com/console/api/apps?page=1')
|
||||
})
|
||||
|
||||
// Scenario: only localhost dev origins should be reflected for credentialed CORS.
|
||||
it('should only allow local development origins', () => {
|
||||
// Assert
|
||||
expect(isAllowedDevOrigin('http://localhost:3000')).toBe(true)
|
||||
expect(isAllowedDevOrigin('http://127.0.0.1:3000')).toBe(true)
|
||||
expect(isAllowedDevOrigin('https://example.com')).toBe(false)
|
||||
})
|
||||
|
||||
// Scenario: proxy requests should rewrite cookies and surface credentialed CORS headers.
|
||||
it('should proxy api requests through Hono with local cookie rewriting', async () => {
|
||||
// Arrange
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok', {
|
||||
status: 200,
|
||||
headers: [
|
||||
['content-encoding', 'br'],
|
||||
['content-length', '123'],
|
||||
['set-cookie', '__Host-access_token=abc; Path=/console/api; Domain=cloud.dify.ai; Secure; SameSite=None'],
|
||||
['transfer-encoding', 'chunked'],
|
||||
],
|
||||
}))
|
||||
const app = createDevProxyApp({
|
||||
consoleApiTarget: 'https://cloud.dify.ai',
|
||||
publicApiTarget: 'https://public.dify.ai',
|
||||
fetchImpl,
|
||||
})
|
||||
|
||||
// Act
|
||||
const response = await app.request('http://127.0.0.1:5001/console/api/apps?page=1', {
|
||||
headers: {
|
||||
Origin: 'http://localhost:3000',
|
||||
Cookie: 'access_token=abc',
|
||||
},
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
new URL('https://cloud.dify.ai/console/api/apps?page=1'),
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
)
|
||||
|
||||
const [, requestInit] = fetchImpl.mock.calls[0]
|
||||
const requestHeaders = requestInit?.headers as Headers
|
||||
expect(requestHeaders.get('cookie')).toBe('__Host-access_token=abc')
|
||||
expect(requestHeaders.get('origin')).toBe('https://cloud.dify.ai')
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:3000')
|
||||
expect(response.headers.get('access-control-allow-credentials')).toBe('true')
|
||||
expect(response.headers.get('content-encoding')).toBeNull()
|
||||
expect(response.headers.get('content-length')).toBeNull()
|
||||
expect(response.headers.get('transfer-encoding')).toBeNull()
|
||||
expect(response.headers.getSetCookie()).toEqual([
|
||||
'access_token=abc; Path=/; SameSite=Lax',
|
||||
])
|
||||
})
|
||||
|
||||
// Scenario: preflight requests should advertise allowed headers for credentialed cross-origin calls.
|
||||
it('should answer CORS preflight requests', async () => {
|
||||
// Arrange
|
||||
const app = createDevProxyApp({
|
||||
consoleApiTarget: 'https://cloud.dify.ai',
|
||||
publicApiTarget: 'https://public.dify.ai',
|
||||
fetchImpl: vi.fn<typeof fetch>(),
|
||||
})
|
||||
|
||||
// Act
|
||||
const response = await app.request('http://127.0.0.1:5001/api/messages', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
'Origin': 'http://localhost:3000',
|
||||
'Access-Control-Request-Headers': 'authorization,content-type,x-csrf-token',
|
||||
},
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:3000')
|
||||
expect(response.headers.get('access-control-allow-credentials')).toBe('true')
|
||||
expect(response.headers.get('access-control-allow-headers')).toBe('authorization,content-type,x-csrf-token')
|
||||
})
|
||||
})
|
||||
202
web/plugins/dev-proxy/server.ts
Normal file
202
web/plugins/dev-proxy/server.ts
Normal file
@ -0,0 +1,202 @@
|
||||
import type { Context, Hono } from 'hono'
|
||||
import { Hono as HonoApp } from 'hono'
|
||||
import { DEFAULT_PROXY_TARGET, rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } from './cookies'
|
||||
|
||||
type DevProxyEnv = Partial<Record<
|
||||
| 'HONO_CONSOLE_API_PROXY_TARGET'
|
||||
| 'HONO_PUBLIC_API_PROXY_TARGET',
|
||||
string
|
||||
>>
|
||||
|
||||
export type DevProxyTargets = {
|
||||
consoleApiTarget: string
|
||||
publicApiTarget: string
|
||||
}
|
||||
|
||||
type DevProxyAppOptions = DevProxyTargets & {
|
||||
fetchImpl?: typeof globalThis.fetch
|
||||
}
|
||||
|
||||
const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]'])
|
||||
const ALLOW_METHODS = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS'
|
||||
const DEFAULT_ALLOW_HEADERS = 'Authorization, Content-Type, X-CSRF-Token'
|
||||
const RESPONSE_HEADERS_TO_DROP = [
|
||||
'connection',
|
||||
'content-encoding',
|
||||
'content-length',
|
||||
'keep-alive',
|
||||
'set-cookie',
|
||||
'transfer-encoding',
|
||||
] as const
|
||||
|
||||
const appendHeaderValue = (headers: Headers, name: string, value: string) => {
|
||||
const currentValue = headers.get(name)
|
||||
if (!currentValue) {
|
||||
headers.set(name, value)
|
||||
return
|
||||
}
|
||||
|
||||
if (currentValue.split(',').map(item => item.trim()).includes(value))
|
||||
return
|
||||
|
||||
headers.set(name, `${currentValue}, ${value}`)
|
||||
}
|
||||
|
||||
export const isAllowedDevOrigin = (origin?: string | null) => {
|
||||
if (!origin)
|
||||
return false
|
||||
|
||||
try {
|
||||
const url = new URL(origin)
|
||||
return LOCAL_DEV_HOSTS.has(url.hostname)
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const applyCorsHeaders = (headers: Headers, origin?: string | null) => {
|
||||
if (!isAllowedDevOrigin(origin))
|
||||
return
|
||||
|
||||
headers.set('Access-Control-Allow-Origin', origin!)
|
||||
headers.set('Access-Control-Allow-Credentials', 'true')
|
||||
appendHeaderValue(headers, 'Vary', 'Origin')
|
||||
}
|
||||
|
||||
export const buildUpstreamUrl = (target: string, requestPath: string, search = '') => {
|
||||
const targetUrl = new URL(target)
|
||||
const normalizedTargetPath = targetUrl.pathname === '/' ? '' : targetUrl.pathname.replace(/\/$/, '')
|
||||
const normalizedRequestPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}`
|
||||
const hasTargetPrefix = normalizedTargetPath
|
||||
&& (normalizedRequestPath === normalizedTargetPath || normalizedRequestPath.startsWith(`${normalizedTargetPath}/`))
|
||||
|
||||
targetUrl.pathname = hasTargetPrefix
|
||||
? normalizedRequestPath
|
||||
: `${normalizedTargetPath}${normalizedRequestPath}`
|
||||
targetUrl.search = search
|
||||
|
||||
return targetUrl
|
||||
}
|
||||
|
||||
const createProxyRequestHeaders = (request: Request, targetUrl: URL) => {
|
||||
const headers = new Headers(request.headers)
|
||||
headers.delete('host')
|
||||
|
||||
if (headers.has('origin'))
|
||||
headers.set('origin', targetUrl.origin)
|
||||
|
||||
const rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined)
|
||||
if (rewrittenCookieHeader)
|
||||
headers.set('cookie', rewrittenCookieHeader)
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
const createUpstreamResponseHeaders = (response: Response, requestOrigin?: string | null) => {
|
||||
const headers = new Headers(response.headers)
|
||||
RESPONSE_HEADERS_TO_DROP.forEach(header => headers.delete(header))
|
||||
|
||||
const rewrittenSetCookies = rewriteSetCookieHeadersForLocal(response.headers.getSetCookie())
|
||||
rewrittenSetCookies?.forEach((cookie) => {
|
||||
headers.append('set-cookie', cookie)
|
||||
})
|
||||
|
||||
applyCorsHeaders(headers, requestOrigin)
|
||||
return headers
|
||||
}
|
||||
|
||||
const proxyRequest = async (
|
||||
context: Context,
|
||||
target: string,
|
||||
fetchImpl: typeof globalThis.fetch,
|
||||
) => {
|
||||
const requestUrl = new URL(context.req.url)
|
||||
const targetUrl = buildUpstreamUrl(target, requestUrl.pathname, requestUrl.search)
|
||||
const requestHeaders = createProxyRequestHeaders(context.req.raw, targetUrl)
|
||||
const requestInit: RequestInit & { duplex?: 'half' } = {
|
||||
method: context.req.method,
|
||||
headers: requestHeaders,
|
||||
redirect: 'manual',
|
||||
}
|
||||
|
||||
if (context.req.method !== 'GET' && context.req.method !== 'HEAD') {
|
||||
requestInit.body = context.req.raw.body
|
||||
requestInit.duplex = 'half'
|
||||
}
|
||||
|
||||
const upstreamResponse = await fetchImpl(targetUrl, requestInit)
|
||||
const responseHeaders = createUpstreamResponseHeaders(upstreamResponse, context.req.header('origin'))
|
||||
|
||||
return new Response(upstreamResponse.body, {
|
||||
status: upstreamResponse.status,
|
||||
statusText: upstreamResponse.statusText,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
const registerProxyRoute = (
|
||||
app: Hono,
|
||||
path: '/console/api' | '/api',
|
||||
target: string,
|
||||
fetchImpl: typeof globalThis.fetch,
|
||||
) => {
|
||||
app.all(path, context => proxyRequest(context, target, fetchImpl))
|
||||
app.all(`${path}/*`, context => proxyRequest(context, target, fetchImpl))
|
||||
}
|
||||
|
||||
export const resolveDevProxyTargets = (env: DevProxyEnv = {}): DevProxyTargets => {
|
||||
const consoleApiTarget = env.HONO_CONSOLE_API_PROXY_TARGET
|
||||
|| DEFAULT_PROXY_TARGET
|
||||
const publicApiTarget = env.HONO_PUBLIC_API_PROXY_TARGET
|
||||
|| consoleApiTarget
|
||||
|
||||
return {
|
||||
consoleApiTarget,
|
||||
publicApiTarget,
|
||||
}
|
||||
}
|
||||
|
||||
export const createDevProxyApp = (options: DevProxyAppOptions) => {
|
||||
const app = new HonoApp()
|
||||
const fetchImpl = options.fetchImpl || globalThis.fetch
|
||||
|
||||
app.onError((error, context) => {
|
||||
console.error('[dev-hono-proxy]', error)
|
||||
|
||||
const headers = new Headers()
|
||||
applyCorsHeaders(headers, context.req.header('origin'))
|
||||
|
||||
return new Response('Upstream proxy request failed.', {
|
||||
status: 502,
|
||||
headers,
|
||||
})
|
||||
})
|
||||
|
||||
app.use('*', async (context, next) => {
|
||||
if (context.req.method === 'OPTIONS') {
|
||||
const headers = new Headers()
|
||||
applyCorsHeaders(headers, context.req.header('origin'))
|
||||
headers.set('Access-Control-Allow-Methods', ALLOW_METHODS)
|
||||
headers.set(
|
||||
'Access-Control-Allow-Headers',
|
||||
context.req.header('Access-Control-Request-Headers') || DEFAULT_ALLOW_HEADERS,
|
||||
)
|
||||
if (context.req.header('Access-Control-Request-Private-Network') === 'true')
|
||||
headers.set('Access-Control-Allow-Private-Network', 'true')
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
await next()
|
||||
applyCorsHeaders(context.res.headers, context.req.header('origin'))
|
||||
})
|
||||
|
||||
registerProxyRoute(app, '/console/api', options.consoleApiTarget, fetchImpl)
|
||||
registerProxyRoute(app, '/api', options.publicApiTarget, fetchImpl)
|
||||
|
||||
return app
|
||||
}
|
||||
1007
web/pnpm-lock.yaml
generated
1007
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
21
web/scripts/dev-hono-proxy.ts
Normal file
21
web/scripts/dev-hono-proxy.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { loadEnv } from 'vite'
|
||||
import { createDevProxyApp, resolveDevProxyTargets } from '../plugins/dev-proxy/server'
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const mode = process.env.MODE || process.env.NODE_ENV || 'development'
|
||||
const env = loadEnv(mode, projectRoot, '')
|
||||
|
||||
const host = env.HONO_PROXY_HOST || '127.0.0.1'
|
||||
const port = Number(env.HONO_PROXY_PORT || 5001)
|
||||
const app = createDevProxyApp(resolveDevProxyTargets(env))
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
hostname: host,
|
||||
port,
|
||||
})
|
||||
|
||||
console.log(`[dev-hono-proxy] listening on http://${host}:${port}`)
|
||||
19
web/taze.config.js
Normal file
19
web/taze.config.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'taze'
|
||||
|
||||
export default defineConfig({
|
||||
exclude: [
|
||||
// We are going to replace these
|
||||
'react-syntax-highlighter',
|
||||
'react-window',
|
||||
'@types/react-window',
|
||||
|
||||
// We can not upgrade these yet
|
||||
'tailwind-merge',
|
||||
'tailwindcss',
|
||||
],
|
||||
|
||||
write: true,
|
||||
install: false,
|
||||
recursive: true,
|
||||
interactive: true,
|
||||
})
|
||||
@ -1,10 +1,11 @@
|
||||
/// <reference types="vitest/config" />
|
||||
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import vinext from 'vinext'
|
||||
import { defineConfig } from 'vite'
|
||||
import Inspect from 'vite-plugin-inspect'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector'
|
||||
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr'
|
||||
|
||||
@ -20,8 +21,6 @@ export default defineConfig(({ mode }) => {
|
||||
return {
|
||||
plugins: isTest
|
||||
? [
|
||||
// TODO: remove tsconfigPaths from test config after vitest supports it natively
|
||||
tsconfigPaths(),
|
||||
react(),
|
||||
{
|
||||
// Stub .mdx files so components importing them can be unit-tested
|
||||
@ -46,7 +45,8 @@ export default defineConfig(({ mode }) => {
|
||||
injectTarget: browserInitializerInjectTarget,
|
||||
projectRoot,
|
||||
}),
|
||||
vinext(),
|
||||
react(),
|
||||
vinext({ react: false }),
|
||||
customI18nHmrPlugin({ injectTarget: browserInitializerInjectTarget }),
|
||||
// reactGrabOpenFilePlugin({
|
||||
// injectTarget: browserInitializerInjectTarget,
|
||||
@ -78,6 +78,7 @@ export default defineConfig(({ mode }) => {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
reporters: ['agent'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'],
|
||||
|
||||
Reference in New Issue
Block a user