mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
4 Commits
refactor/i
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7b4748ce5 | ||
|
|
22d32fee8c | ||
|
|
c3ef748119 | ||
|
|
6b7d7135e2 |
@@ -0,0 +1,211 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, waitFor } from '@testing-library/vue'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import TabSubgraphInputs from './TabSubgraphInputs.vue'
|
||||
|
||||
const {
|
||||
mockDraggable,
|
||||
mockDropTargetForElements,
|
||||
capturedDropHandlers,
|
||||
mockDraggableCleanup,
|
||||
mockDropTargetCleanup
|
||||
} = vi.hoisted(() => {
|
||||
const capturedDropHandlers: Array<
|
||||
(args: { source: { data: Record<string, unknown> } }) => void
|
||||
> = []
|
||||
const mockDraggableCleanup = vi.fn()
|
||||
const mockDropTargetCleanup = vi.fn()
|
||||
const mockDraggable = vi.fn(() => mockDraggableCleanup)
|
||||
const mockDropTargetForElements = vi.fn(
|
||||
(config: {
|
||||
element: HTMLElement
|
||||
onDrop: (args: { source: { data: Record<string, unknown> } }) => void
|
||||
}) => {
|
||||
capturedDropHandlers.push(config.onDrop)
|
||||
return mockDropTargetCleanup
|
||||
}
|
||||
)
|
||||
return {
|
||||
mockDraggable,
|
||||
mockDropTargetForElements,
|
||||
capturedDropHandlers,
|
||||
mockDraggableCleanup,
|
||||
mockDropTargetCleanup
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({
|
||||
draggable: mockDraggable,
|
||||
dropTargetForElements: mockDropTargetForElements
|
||||
}))
|
||||
|
||||
const mockSetDirty = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: { setDirty: mockSetDirty } })
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue',
|
||||
() => ({ default: { template: '<div />' } })
|
||||
)
|
||||
|
||||
vi.mock('@/components/rightSidePanel/layout/CollapseToggleButton.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}))
|
||||
|
||||
vi.mock('./SectionWidgets.vue', async () => {
|
||||
const { defineComponent: dc, ref: r } = await import('vue')
|
||||
return {
|
||||
default: dc({
|
||||
name: 'SectionWidgets',
|
||||
props: [
|
||||
'widgets',
|
||||
'collapse',
|
||||
'label',
|
||||
'node',
|
||||
'parents',
|
||||
'isDraggable',
|
||||
'enableEmptyState',
|
||||
'tooltip',
|
||||
'showNodeName'
|
||||
],
|
||||
emits: ['update:collapse'],
|
||||
setup(
|
||||
_: unknown,
|
||||
{ expose }: { expose: (exposed: Record<string, unknown>) => void }
|
||||
) {
|
||||
const container = r<HTMLElement | undefined>(undefined)
|
||||
expose({
|
||||
widgetsContainer: container,
|
||||
rootElement: r<HTMLElement | undefined>(undefined)
|
||||
})
|
||||
return { container }
|
||||
},
|
||||
template: `<div><div ref="container"><div class="draggable-item" /><div class="draggable-item" /></div></div>`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
rightSidePanel: {
|
||||
inputs: 'Inputs',
|
||||
inputsNone: 'No Inputs',
|
||||
inputsNoneTooltip: 'No inputs tooltip',
|
||||
advancedInputs: 'Advanced Inputs',
|
||||
noneSearchDesc: 'No results found'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createMockNode(): SubgraphNode {
|
||||
return fromAny<SubgraphNode, unknown>({
|
||||
id: '1',
|
||||
rootGraph: { id: 'graph-1' },
|
||||
widgets: [],
|
||||
subgraph: { nodes: [] }
|
||||
})
|
||||
}
|
||||
|
||||
function renderComponent(node: SubgraphNode = createMockNode()) {
|
||||
return render(TabSubgraphInputs, {
|
||||
props: { node },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('TabSubgraphInputs drag-and-drop', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
capturedDropHandlers.length = 0
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('attaches draggable and drop-target handlers to each item on mount', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(mockDraggable).toHaveBeenCalledTimes(2)
|
||||
expect(mockDropTargetForElements).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('runs cleanup for all registered handlers on unmount', () => {
|
||||
const { unmount } = renderComponent()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockDraggableCleanup).toHaveBeenCalledTimes(2)
|
||||
expect(mockDropTargetCleanup).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('calls movePromotion with correct indices when an item is dropped', () => {
|
||||
const node = createMockNode()
|
||||
renderComponent(node)
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
vi.spyOn(promotionStore, 'movePromotion')
|
||||
|
||||
capturedDropHandlers[1]({ source: { data: { index: 0 } } })
|
||||
|
||||
expect(promotionStore.movePromotion).toHaveBeenCalledWith(
|
||||
'graph-1',
|
||||
'1',
|
||||
0,
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it('does not call movePromotion when item is dropped onto itself', () => {
|
||||
const node = createMockNode()
|
||||
renderComponent(node)
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
vi.spyOn(promotionStore, 'movePromotion')
|
||||
|
||||
capturedDropHandlers[0]({ source: { data: { index: 0 } } })
|
||||
|
||||
expect(promotionStore.movePromotion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores drop when source index is not a number', () => {
|
||||
renderComponent()
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
vi.spyOn(promotionStore, 'movePromotion')
|
||||
|
||||
capturedDropHandlers[1]({ source: { data: { index: 'invalid' } } })
|
||||
|
||||
expect(promotionStore.movePromotion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls canvas setDirty after a successful drop', () => {
|
||||
const node = createMockNode()
|
||||
renderComponent(node)
|
||||
|
||||
capturedDropHandlers[1]({ source: { data: { index: 0 } } })
|
||||
|
||||
expect(mockSetDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('re-registers drag handlers when promotionEntries changes', async () => {
|
||||
const node = createMockNode()
|
||||
renderComponent(node)
|
||||
mockDraggable.mockClear()
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
promotionStore.setPromotions('graph-1', '1', [
|
||||
fromAny({ sourceNodeId: 'node-1', widgetName: 'seed' })
|
||||
])
|
||||
|
||||
await waitFor(() => expect(mockDraggable).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
@@ -19,7 +23,6 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
@@ -51,7 +54,6 @@ const isAllCollapsed = computed({
|
||||
advancedInputsCollapsed.value = collapse
|
||||
}
|
||||
})
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
|
||||
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
|
||||
|
||||
@@ -146,65 +148,59 @@ async function searcher(query: string) {
|
||||
searchedWidgetsList.value = searchWidgets(widgetsList.value, query)
|
||||
}
|
||||
|
||||
const isMounted = useMounted()
|
||||
let cleanupDragAndDrop = () => {}
|
||||
|
||||
function setDraggableState() {
|
||||
if (!isMounted.value) return
|
||||
cleanupDragAndDrop()
|
||||
cleanupDragAndDrop = () => {}
|
||||
|
||||
draggableList.value?.dispose()
|
||||
const container = sectionWidgetsRef.value?.widgetsContainer
|
||||
if (isSearching.value || !container?.children?.length) return
|
||||
|
||||
draggableList.value = new DraggableList(container, '.draggable-item')
|
||||
const items = Array.from(
|
||||
container.querySelectorAll('.draggable-item')
|
||||
) as HTMLElement[]
|
||||
if (items.length === 0) return
|
||||
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems: HTMLElement[] = []
|
||||
const cleanups: Array<() => void> = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
if (oldPosition === -1) {
|
||||
console.error('[TabSubgraphInputs] draggableItem not found in items')
|
||||
return
|
||||
}
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = reorderedItems.indexOf(
|
||||
this.draggableItem as HTMLElement
|
||||
items.forEach((item, index) => {
|
||||
cleanups.push(
|
||||
draggable({
|
||||
element: item,
|
||||
getInitialData: () => ({ index }),
|
||||
onDragStart: () => item.classList.add('is-draggable'),
|
||||
onDrop: () => item.classList.remove('is-draggable')
|
||||
})
|
||||
)
|
||||
|
||||
promotionStore.movePromotion(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
oldPosition,
|
||||
newPosition
|
||||
cleanups.push(
|
||||
dropTargetForElements({
|
||||
element: item,
|
||||
onDrop: ({ source }) => {
|
||||
const fromIndex = source.data.index
|
||||
if (typeof fromIndex !== 'number') return
|
||||
if (fromIndex === index) return
|
||||
promotionStore.movePromotion(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
fromIndex,
|
||||
index
|
||||
)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
cleanupDragAndDrop = () => cleanups.forEach((c) => c())
|
||||
}
|
||||
|
||||
watchDebounced(searchedWidgetsList, () => setDraggableState(), {
|
||||
debounce: 100
|
||||
})
|
||||
watch(promotionEntries, () => nextTick(setDraggableState))
|
||||
onMounted(() => setDraggableState())
|
||||
onBeforeUnmount(() => draggableList.value?.dispose())
|
||||
onBeforeUnmount(() => cleanupDragAndDrop())
|
||||
|
||||
const label = computed(() => {
|
||||
return searchedWidgetsList.value.length !== 0
|
||||
|
||||
Reference in New Issue
Block a user