mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
fix(workflow): 收口侧栏拖放生命周期 (#706)
* fix(workflow): guard sidebar drag lifecycle * test: isolate music progress side effects
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import useDragAndDrop from '@core/utils/workflow'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
addNodes: vi.fn(),
|
||||
screenToFlowCoordinate: vi.fn(),
|
||||
onNodesInitialized: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
off: vi.fn(),
|
||||
onNodesInitializedCallback: undefined as (() => void) | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@vue-flow/core', () => ({
|
||||
useVueFlow: () => ({
|
||||
addNodes: mocks.addNodes,
|
||||
screenToFlowCoordinate: mocks.screenToFlowCoordinate,
|
||||
onNodesInitialized: mocks.onNodesInitialized,
|
||||
updateNode: mocks.updateNode,
|
||||
}),
|
||||
}))
|
||||
|
||||
type DragAndDrop = ReturnType<typeof useDragAndDrop>
|
||||
|
||||
let current: DragAndDrop | undefined
|
||||
let wrapper: ReturnType<typeof mount> | undefined
|
||||
|
||||
const Harness = defineComponent({
|
||||
name: 'WorkflowDragAndDropHarness',
|
||||
setup() {
|
||||
current = useDragAndDrop()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
function mountHarness() {
|
||||
wrapper = mount(Harness)
|
||||
return current!
|
||||
}
|
||||
|
||||
function createDataTransfer() {
|
||||
return {
|
||||
effectAllowed: '',
|
||||
dropEffect: '',
|
||||
setData: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('useDragAndDrop', () => {
|
||||
beforeEach(() => {
|
||||
mocks.addNodes.mockReset()
|
||||
mocks.screenToFlowCoordinate.mockReset().mockImplementation(({ x, y }: { x: number; y: number }) => ({ x, y }))
|
||||
mocks.onNodesInitialized.mockReset().mockImplementation((callback: () => void) => {
|
||||
mocks.onNodesInitializedCallback = callback
|
||||
return { off: mocks.off }
|
||||
})
|
||||
mocks.updateNode.mockReset()
|
||||
mocks.off.mockReset()
|
||||
mocks.onNodesInitializedCallback = undefined
|
||||
document.body.style.userSelect = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
current = undefined
|
||||
document.body.style.userSelect = ''
|
||||
})
|
||||
|
||||
it('does not enter drag-over or create a node before a drag starts', async () => {
|
||||
const dnd = mountHarness()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
dnd.onDragOver({ preventDefault, dataTransfer: createDataTransfer() })
|
||||
dnd.onDrop({ clientX: 10, clientY: 20 })
|
||||
await nextTick()
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(dnd.isDragOver.value).toBe(false)
|
||||
expect(mocks.addNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks drag state, prevents selection, and clears everything on document drop', async () => {
|
||||
const dnd = mountHarness()
|
||||
const dataTransfer = createDataTransfer()
|
||||
const data = { type: 'ScanFileAction', name: '扫描目录' }
|
||||
const addEventListener = vi.spyOn(document, 'addEventListener')
|
||||
const removeEventListener = vi.spyOn(document, 'removeEventListener')
|
||||
|
||||
dnd.onDragStart({ dataTransfer }, data)
|
||||
await nextTick()
|
||||
expect(dataTransfer.setData).toHaveBeenCalledWith('application/vueflow', data)
|
||||
expect(dataTransfer.effectAllowed).toBe('move')
|
||||
expect(dnd.draggedData.value).toEqual(data)
|
||||
expect(dnd.isDragging.value).toBe(true)
|
||||
expect(document.body.style.userSelect).toBe('none')
|
||||
expect(addEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
|
||||
expect(addEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
|
||||
|
||||
const dragOverTransfer = createDataTransfer()
|
||||
const preventDefault = vi.fn()
|
||||
dnd.onDragOver({ preventDefault, dataTransfer: dragOverTransfer })
|
||||
await nextTick()
|
||||
expect(dnd.isDragOver.value).toBe(true)
|
||||
expect(dragOverTransfer.dropEffect).toBe('move')
|
||||
dnd.onDragLeave()
|
||||
expect(dnd.isDragOver.value).toBe(false)
|
||||
|
||||
document.dispatchEvent(new Event('drop'))
|
||||
await nextTick()
|
||||
expect(dnd.draggedData.value).toBeNull()
|
||||
expect(dnd.isDragging.value).toBe(false)
|
||||
expect(dnd.isDragOver.value).toBe(false)
|
||||
expect(document.body.style.userSelect).toBe('')
|
||||
expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
|
||||
expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
|
||||
})
|
||||
|
||||
it('clears the drag state when the browser cancels the native drag', async () => {
|
||||
const dnd = mountHarness()
|
||||
const removeEventListener = vi.spyOn(document, 'removeEventListener')
|
||||
|
||||
dnd.onDragStart({ dataTransfer: createDataTransfer() }, { type: 'NoteAction', name: '备注' })
|
||||
await nextTick()
|
||||
dnd.onDragOver({ preventDefault: vi.fn(), dataTransfer: createDataTransfer() })
|
||||
await nextTick()
|
||||
|
||||
document.dispatchEvent(new Event('dragend'))
|
||||
await nextTick()
|
||||
|
||||
expect(dnd.draggedData.value).toBeNull()
|
||||
expect(dnd.isDragging.value).toBe(false)
|
||||
expect(dnd.isDragOver.value).toBe(false)
|
||||
expect(document.body.style.userSelect).toBe('')
|
||||
expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
|
||||
expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
|
||||
})
|
||||
|
||||
it('deep clones dropped data and centers the node after Vue Flow initializes it', async () => {
|
||||
const dnd = mountHarness()
|
||||
const data = {
|
||||
type: 'ScanFileAction',
|
||||
name: '扫描目录',
|
||||
description: '扫描媒体目录',
|
||||
data: { nested: { enabled: true } },
|
||||
}
|
||||
|
||||
dnd.onDragStart({ dataTransfer: createDataTransfer() }, data)
|
||||
dnd.onDrop({ clientX: 80, clientY: 120 })
|
||||
|
||||
expect(mocks.screenToFlowCoordinate).toHaveBeenCalledWith({ x: 80, y: 120 })
|
||||
expect(mocks.addNodes).toHaveBeenCalledOnce()
|
||||
const node = mocks.addNodes.mock.calls[0][0]
|
||||
expect(node).toMatchObject({
|
||||
type: 'ScanFileAction',
|
||||
name: '扫描目录',
|
||||
description: '扫描媒体目录',
|
||||
position: { x: 80, y: 120 },
|
||||
data: { nested: { enabled: true } },
|
||||
})
|
||||
expect(node.id).toMatch(/^act_/)
|
||||
expect(node.data).not.toBe(data.data)
|
||||
|
||||
data.data.nested.enabled = false
|
||||
expect(node.data.nested.enabled).toBe(true)
|
||||
|
||||
expect(mocks.onNodesInitialized).toHaveBeenCalledOnce()
|
||||
mocks.onNodesInitializedCallback?.()
|
||||
expect(mocks.updateNode).toHaveBeenCalledWith(node.id, expect.any(Function))
|
||||
const update = mocks.updateNode.mock.calls[0][1] as (node: {
|
||||
position: { x: number; y: number }
|
||||
dimensions: { width: number; height: number }
|
||||
}) => { position: { x: number; y: number } }
|
||||
expect(update({ position: { x: 80, y: 120 }, dimensions: { width: 40, height: 20 } })).toEqual({
|
||||
position: { x: 60, y: 110 },
|
||||
})
|
||||
expect(mocks.off).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cleans the global selection style and drop listener when unmounted during a drag', async () => {
|
||||
const dnd = mountHarness()
|
||||
const removeEventListener = vi.spyOn(document, 'removeEventListener')
|
||||
|
||||
dnd.onDragStart({ dataTransfer: createDataTransfer() }, { type: 'NoteAction', name: '备注' })
|
||||
await nextTick()
|
||||
expect(document.body.style.userSelect).toBe('none')
|
||||
|
||||
wrapper!.unmount()
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.style.userSelect).toBe('')
|
||||
expect(dnd.isDragging.value).toBe(false)
|
||||
expect(dnd.isDragOver.value).toBe(false)
|
||||
expect(dnd.draggedData.value).toBeNull()
|
||||
expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
|
||||
expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useVueFlow } from '@vue-flow/core'
|
||||
import { ref, watch } from 'vue'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
/**
|
||||
@@ -18,7 +18,7 @@ const state = {
|
||||
/**
|
||||
* The type of the node being dragged.
|
||||
*/
|
||||
draggedData: ref<any | null>({}),
|
||||
draggedData: ref<any | null>(null),
|
||||
isDragOver: ref(false),
|
||||
isDragging: ref(false),
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export default function useDragAndDrop() {
|
||||
const { draggedData, isDragOver, isDragging } = state
|
||||
|
||||
const { addNodes, screenToFlowCoordinate, onNodesInitialized, updateNode } = useVueFlow()
|
||||
let ownsDragListeners = false
|
||||
|
||||
watch(isDragging, dragging => {
|
||||
document.body.style.userSelect = dragging ? 'none' : ''
|
||||
@@ -42,6 +43,8 @@ export default function useDragAndDrop() {
|
||||
isDragging.value = true
|
||||
|
||||
document.addEventListener('drop', onDragEnd)
|
||||
document.addEventListener('dragend', onDragEnd)
|
||||
ownsDragListeners = true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +73,8 @@ export default function useDragAndDrop() {
|
||||
isDragOver.value = false
|
||||
draggedData.value = null
|
||||
document.removeEventListener('drop', onDragEnd)
|
||||
document.removeEventListener('dragend', onDragEnd)
|
||||
ownsDragListeners = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,6 +83,8 @@ export default function useDragAndDrop() {
|
||||
* @param {DragEvent} event
|
||||
*/
|
||||
function onDrop(event: any) {
|
||||
if (!draggedData.value) return
|
||||
|
||||
const position = screenToFlowCoordinate({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
@@ -110,6 +117,12 @@ export default function useDragAndDrop() {
|
||||
addNodes(newNode)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (!ownsDragListeners) return
|
||||
onDragEnd()
|
||||
document.body.style.userSelect = ''
|
||||
})
|
||||
|
||||
return {
|
||||
draggedData,
|
||||
isDragOver,
|
||||
|
||||
@@ -102,7 +102,7 @@ watch(
|
||||
// 获取动作步骤文本
|
||||
function getActionStepText(type: string | undefined) {
|
||||
if (!type) return ''
|
||||
return actionStepDict[type]
|
||||
return actionStepDict[type] || type
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import WorkflowSidebar from '@/components/workflow/WorkflowSidebar.vue'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import { fireEvent, waitFor } from '@testing-library/vue'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
onDragStart: vi.fn(),
|
||||
smAndDown: { value: false },
|
||||
appMode: { value: false },
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@core/utils/workflow', () => ({
|
||||
default: () => ({ onDragStart: mocks.onDragStart }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePWA', () => ({
|
||||
usePWA: () => ({ appMode: mocks.appMode }),
|
||||
}))
|
||||
|
||||
vi.mock('vuetify', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('vuetify')>()),
|
||||
useDisplay: () => ({ smAndDown: mocks.smAndDown }),
|
||||
}))
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
name: 'VBtn',
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
const IconButtonStub = defineComponent({
|
||||
name: 'IconBtn',
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
const IconStub = defineComponent({
|
||||
name: 'VIcon',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup(props, { attrs }) {
|
||||
return () => h('span', { ...attrs, 'data-icon': props.icon })
|
||||
},
|
||||
})
|
||||
|
||||
const BoxStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h('div', attrs, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
function mountSidebar(): VueWrapper {
|
||||
const wrapper = mount(WorkflowSidebar, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
VBtn: ButtonStub,
|
||||
VIcon: IconStub,
|
||||
VAvatar: BoxStub,
|
||||
VCard: BoxStub,
|
||||
IconBtn: IconButtonStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
mountedWrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
let mountedWrappers: VueWrapper[] = []
|
||||
|
||||
describe('WorkflowSidebar', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset().mockResolvedValue([
|
||||
{ name: '扫描目录', type: 'ScanFileAction' },
|
||||
{ name: 'UnknownActionName', type: 'UnknownAction' },
|
||||
])
|
||||
mocks.onDragStart.mockReset()
|
||||
mocks.smAndDown.value = false
|
||||
mocks.appMode.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mountedWrappers.forEach(wrapper => wrapper.unmount())
|
||||
mountedWrappers = []
|
||||
})
|
||||
|
||||
it('loads actions, maps known icons, and keeps unknown action names visible', async () => {
|
||||
const wrapper = mountSidebar()
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('workflow/actions'))
|
||||
await waitFor(() => expect(wrapper.findAll('.component-item')).toHaveLength(2))
|
||||
|
||||
const items = wrapper.findAll('.component-item')
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0].find('.component-name').text()).toBe('Scan Directory')
|
||||
expect(items[0].find('[data-icon]').attributes('data-icon')).toBe('mdi-folder-search')
|
||||
expect(items[1].find('.component-name').text()).toBe('UnknownActionName')
|
||||
expect(items[1].find('[data-icon]').attributes('data-icon')).toBe('mdi-puzzle-outline')
|
||||
})
|
||||
|
||||
it('keeps an API loading failure from rendering stale or malformed actions', async () => {
|
||||
const error = new Error('workflow actions unavailable')
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mocks.apiGet.mockRejectedValueOnce(error)
|
||||
|
||||
const wrapper = mountSidebar()
|
||||
|
||||
await waitFor(() => expect(consoleError).toHaveBeenCalledWith(error))
|
||||
expect(wrapper.findAll('.component-item')).toHaveLength(0)
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('collapses only on desktop and starts drag without treating a click as a drag', async () => {
|
||||
const wrapper = mountSidebar()
|
||||
await waitFor(() => expect(wrapper.findAll('.component-item')).toHaveLength(2))
|
||||
|
||||
expect(wrapper.find('.workflow-sidebar').classes()).not.toContain('sidebar-collapsed')
|
||||
await fireEvent.click(wrapper.find('.collapse-btn').element)
|
||||
expect(wrapper.find('.workflow-sidebar').classes()).toContain('sidebar-collapsed')
|
||||
expect(wrapper.findAll('.component-info')).toHaveLength(0)
|
||||
|
||||
await fireEvent.click(wrapper.find('.component-item').element)
|
||||
expect(mocks.onDragStart).not.toHaveBeenCalled()
|
||||
|
||||
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
|
||||
await fireEvent.dragStart(wrapper.find('.component-item').element, { dataTransfer })
|
||||
expect(mocks.onDragStart).toHaveBeenCalledWith(expect.any(Event), expect.objectContaining({ name: '扫描目录' }))
|
||||
})
|
||||
|
||||
it('opens the mobile sidebar, emits the selected action, and closes after selection', async () => {
|
||||
mocks.smAndDown.value = true
|
||||
mocks.appMode.value = true
|
||||
const wrapper = mountSidebar()
|
||||
await waitFor(() => expect(wrapper.findAll('.component-item')).toHaveLength(2))
|
||||
|
||||
const trigger = wrapper.find('.workflow-sidebar-trigger')
|
||||
expect(trigger.exists()).toBe(true)
|
||||
expect(trigger.classes()).toContain('bottom-28')
|
||||
expect(wrapper.find('.workflow-sidebar').classes()).toContain('sidebar-mobile')
|
||||
expect(wrapper.find('.component-item').attributes('draggable')).toBe('false')
|
||||
|
||||
await fireEvent.click(trigger.element)
|
||||
expect(wrapper.find('.workflow-sidebar').classes()).toContain('sidebar-mobile-open')
|
||||
expect(wrapper.find('.workflow-sidebar-fab [data-icon]').attributes('data-icon')).toBe('mdi-close')
|
||||
|
||||
await fireEvent.click(wrapper.find('.component-item').element)
|
||||
expect(wrapper.emitted('component-click')).toEqual([[{ name: '扫描目录', type: 'ScanFileAction' }]])
|
||||
expect(wrapper.find('.workflow-sidebar').classes()).not.toContain('sidebar-mobile-open')
|
||||
expect(mocks.onDragStart).not.toHaveBeenCalled()
|
||||
|
||||
mocks.smAndDown.value = false
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('.workflow-sidebar').classes()).not.toContain('sidebar-mobile-open')
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,12 @@ vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/nprogress', () => ({
|
||||
configureNProgress: vi.fn(),
|
||||
doneNProgress: vi.fn(),
|
||||
startNProgress: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: createDataApiMock({
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
|
||||
@@ -309,6 +309,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/utils/permission.ts',
|
||||
'src/utils/pluginSidebarNav.ts',
|
||||
'src/utils/requestOptimizer.ts',
|
||||
'src/@core/utils/workflow.ts',
|
||||
'src/utils/backgroundManager.ts',
|
||||
'src/utils/sseManager.ts',
|
||||
'src/utils/federationLoader.ts',
|
||||
@@ -363,6 +364,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/components/dialog/UserAddEditDialog.vue',
|
||||
'src/components/dialog/WorkflowAddEditDialog.vue',
|
||||
'src/components/dialog/WorkflowActionsDialog.vue',
|
||||
'src/components/workflow/WorkflowSidebar.vue',
|
||||
'src/components/dialog/OTPAuthDialog.vue',
|
||||
'src/components/dialog/PasskeyDialog.vue',
|
||||
'src/components/dialog/SiteCookieUpdateDialog.vue',
|
||||
@@ -571,6 +573,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/components/workflow/WorkflowSidebar.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
statements: 85,
|
||||
},
|
||||
'src/@core/utils/workflow.ts': {
|
||||
branches: 85,
|
||||
functions: 90,
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
},
|
||||
'src/components/dialog/OTPAuthDialog.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
|
||||
Reference in New Issue
Block a user