Compare commits

...

4 Commits

Author SHA1 Message Date
Kelly Yang
d7b4748ce5 test: expand TabSubgraphInputs coverage for drag-and-drop edge cases
- Add test for type guard: drop with non-number index does not call movePromotion
- Add test for canvas.setDirty being called after a successful drop
- Add test for promotionEntries watcher re-registering handlers after reorder
2026-04-20 22:04:57 -07:00
Kelly Yang
22d32fee8c fix: address review feedback on TabSubgraphInputs drag-and-drop
- Fix stale index closures after reorder: watch promotionEntries and
  re-register drag handlers via nextTick so consecutive drags pass
  correct fromIndex/toIndex to movePromotion
- Replace `as number` type assertion with typeof guard per project guidelines
- Tighten cleanup test assertion to toHaveBeenCalledTimes(2) to verify
  both registered handlers are disposed on unmount
2026-04-20 21:12:22 -07:00
Kelly Yang
c3ef748119 test: add component tests for TabSubgraphInputs drag-and-drop setup
Covers the pragmatic-dnd integration added in the previous commit:
attach handlers per item on mount, cleanup on unmount, movePromotion
on drop, and no-op when dropping onto the same item.
2026-04-16 18:21:59 -07:00
Kelly Yang
6b7d7135e2 refactor: replace DraggableList with pragmatic-dnd in TabSubgraphInputs
Replace the vanilla JS DraggableList class with @atlaskit/pragmatic-drag-and-drop
in the subgraph inputs panel. Eliminates the imperative DOM manipulation and
complex applyNewItemsOrder override in favour of declarative drag/drop setup
with a straightforward onDrop callback that calls promotionStore.movePromotion
directly.

DraggableList is retained for groupNodeManage.ts (legacy dialog) and the
menu/index.ts re-export for extension compatibility.

Closes #11105
2026-04-16 17:37:56 -07:00
2 changed files with 253 additions and 46 deletions

View File

@@ -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))
})
})

View File

@@ -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