Compare commits

...

9 Commits

Author SHA1 Message Date
DrJKL
b96e455765 test: fix right-panel test stubs and store mocking
- Rename FormSearchInput → AsyncSearchInput stubs (components import
  AsyncSearchInput, so the previous stubs never engaged and tests
  rendered the real component, breaking getByTestId('search-input')).
- Drop `vi.mock(import('pinia'), ...)` identity stubs that violated
  "don't mock what you don't own" and broke `vue-tsc` due to the
  return type being incompatible with `StoreToRefs<SS>`.
- TabSubgraphInputs.test.ts: switch to real `createTestingPinia` for
  the focusedSection consumer (no per-store mock needed).
- RightSidePanel.test.ts: replace plain-object store factories with
  `defineStore`-backed mocks so the real `storeToRefs` works without
  pulling in heavy real-store dependency graphs.

Amp-Thread-ID: https://ampcode.com/threads/T-019e3eaa-a8a9-76f9-b65e-a6099b3f2c68
Co-authored-by: Amp <amp@ampcode.com>
2026-05-18 22:48:17 -07:00
DrJKL
245e447618 Merge remote-tracking branch 'origin/main' into glary/fix-subgraph-search-state
Amp-Thread-ID: https://ampcode.com/threads/T-019e3e6e-d40f-70d3-a7ab-001f61af8989
Co-authored-by: Amp <amp@ampcode.com>

# Conflicts:
#	src/components/rightSidePanel/parameters/TabNodes.vue
#	src/components/rightSidePanel/parameters/TabNormalInputs.vue
2026-05-18 22:15:48 -07:00
Glary-Bot
da5ec439af test: add component tests for right-panel search-state isolation
Adds Vitest component tests covering the diff lines of #11416:

- TabGlobalParameters, TabNodes, TabNormalInputs, TabSubgraphInputs,
  SubgraphEditor: verify default empty searchQuery, search filtering
  on user input, and reset on key-based remount.
- RightSidePanel: verify workflowKey and selectedNodesKey computeds
  drive child remounts when activeWorkflow.path or selection identity
  changes.

31 tests across 7 files, all green via pnpm vitest run.
2026-05-01 00:25:51 +00:00
Alexander Brown
0ed05f474f Merge branch 'main' into glary/fix-subgraph-search-state 2026-04-30 17:08:03 -07:00
Alexander Brown
8efdd44ae2 Merge branch 'main' into glary/fix-subgraph-search-state 2026-04-29 23:10:54 -07:00
GitHub Action
5b691a4ba5 [automated] Apply ESLint and Oxfmt fixes 2026-04-30 02:24:12 +00:00
Alexander Brown
7110645400 Merge branch 'main' into glary/fix-subgraph-search-state 2026-04-29 19:21:11 -07:00
Glary-Bot
774e094f5d fix: address review — hoist createWidget helper, add workflow-scoped keys
- Hoist duplicate createWidget test helper to file scope in shared.test.ts
- Prefix all :key props with workflowKey to prevent ID collisions across
  workflow switches
2026-04-19 09:52:55 +00:00
Glary-Bot
ee50f6ad46 fix: scope right-panel search state to component lifecycle
Remove the shared searchQuery ref from rightSidePanelStore and replace
it with local refs in each tab component. Add :key directives to
TabSubgraphInputs, SubgraphEditor, and TabNormalInputs so Vue
destroys and recreates them when the selected node changes, naturally
resetting search state without any manual watchers.

Also removes the now-dead collapseMap reset watchers from TabNodes and
TabNormalInputs, since :key-driven remounts already clear all local
state.
2026-04-19 09:39:25 +00:00
15 changed files with 1568 additions and 59 deletions

View File

@@ -0,0 +1,202 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
async function createTwoSubgraphs(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphA = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const clipTextEncode = await comfyPage.nodeOps.getNodeRefById('6')
await clipTextEncode.click('title')
const subgraphB = await clipTextEncode.convertToSubgraph()
await comfyPage.nextFrame()
return { subgraphA, subgraphB }
}
async function selectNodeById(comfyPage: ComfyPage, nodeId: string) {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(nodeId)
await nodeRef.click('title')
await comfyPage.nextFrame()
}
test.describe(
'Properties panel - Search state scoping',
{ tag: ['@subgraph'] },
() => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
})
test('search resets when selecting a different subgraph node', async ({
comfyPage
}) => {
const { subgraphA, subgraphB } = await createTwoSubgraphs(comfyPage)
await subgraphA.click('title')
await comfyPage.nextFrame()
await panel.searchWidgets('seed')
await expect(panel.searchBox).toHaveValue('seed')
await subgraphB.click('title')
await comfyPage.nextFrame()
await expect(panel.searchBox).toHaveValue('')
})
test('search resets in subgraph editor when selecting different subgraph', async ({
comfyPage
}) => {
const { subgraphA, subgraphB } = await createTwoSubgraphs(comfyPage)
await subgraphA.click('title')
await comfyPage.nextFrame()
await expect(panel.subgraphEditButton).toBeVisible()
await panel.subgraphEditButton.click()
await comfyPage.nextFrame()
await panel.searchWidgets('seed')
await expect(panel.searchBox).toHaveValue('seed')
await subgraphB.click('title')
await comfyPage.nextFrame()
await expect(panel.searchBox).toHaveValue('')
})
test('search resets when going from subgraph to normal node', async ({
comfyPage
}) => {
const { subgraphA } = await createTwoSubgraphs(comfyPage)
await subgraphA.click('title')
await comfyPage.nextFrame()
await panel.searchWidgets('seed')
await expect(panel.searchBox).toHaveValue('seed')
await selectNodeById(comfyPage, '4')
await expect(panel.searchBox).toHaveValue('')
})
test('search resets when deselecting all nodes', async ({ comfyPage }) => {
const { subgraphA } = await createTwoSubgraphs(comfyPage)
await subgraphA.click('title')
await comfyPage.nextFrame()
await panel.searchWidgets('seed')
await expect(panel.searchBox).toHaveValue('seed')
await comfyPage.canvasOps.click({ x: 10, y: 10 })
await comfyPage.nextFrame()
await expect(panel.searchBox).toHaveValue('')
})
test('search resets when going from no selection to node selection', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await panel.searchWidgets('seed')
await expect(panel.searchBox).toHaveValue('seed')
await selectNodeById(comfyPage, '3')
await expect(panel.searchBox).toHaveValue('')
})
test('search resets when switching tabs', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await selectNodeById(comfyPage, '3')
await panel.searchWidgets('seed')
await expect(panel.searchBox).toHaveValue('seed')
await panel.switchToTab('Settings')
await expect(panel.searchBox).toHaveValue('')
})
test('search resets when toggling subgraph editor mode', async ({
comfyPage
}) => {
const { subgraphA } = await createTwoSubgraphs(comfyPage)
await subgraphA.click('title')
await comfyPage.nextFrame()
await expect(panel.subgraphEditButton).toBeVisible()
await panel.searchWidgets('seed')
await expect(panel.searchBox).toHaveValue('seed')
await panel.subgraphEditButton.click()
await comfyPage.nextFrame()
await expect(panel.searchBox).toHaveValue('')
})
test('search resets when switching between normal nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await selectNodeById(comfyPage, '3')
await panel.searchWidgets('seed')
await expect(panel.searchBox).toHaveValue('seed')
await selectNodeById(comfyPage, '4')
await expect(panel.searchBox).toHaveValue('')
})
test('widgets render immediately after subgraph switch without flicker', async ({
comfyPage
}) => {
const { subgraphA, subgraphB } = await createTwoSubgraphs(comfyPage)
await subgraphA.click('title')
await comfyPage.nextFrame()
await panel.searchWidgets('seed')
await expect(panel.searchBox).toHaveValue('seed')
await subgraphB.click('title')
await comfyPage.nextFrame()
await expect(panel.searchBox).toHaveValue('')
await expect(panel.contentArea).toBeVisible()
await expect
.poll(() => panel.contentArea.getByText('text').count())
.toBeGreaterThan(0)
})
test('search works correctly in new subgraph after switching', async ({
comfyPage
}) => {
const { subgraphA, subgraphB } = await createTwoSubgraphs(comfyPage)
await subgraphA.click('title')
await comfyPage.nextFrame()
await panel.searchWidgets('seed')
await expect(panel.searchBox).toHaveValue('seed')
await subgraphB.click('title')
await comfyPage.nextFrame()
await expect(panel.searchBox).toHaveValue('')
await panel.searchWidgets('seed')
await expect(panel.searchBox).toHaveValue('seed')
await expect
.poll(() =>
panel.contentArea
.getByText(/no .* match|no results|no items/i)
.count()
)
.toBeGreaterThan(0)
})
}
)

View File

@@ -0,0 +1,371 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import RightSidePanel from './RightSidePanel.vue'
const mockActiveWorkflow = ref<{ path: string } | null>({ path: 'wf-1' })
const mockSidebarLocation = ref<'left' | 'right'>('right')
vi.mock('@/renderer/core/canvas/canvasStore', async () => {
const { defineStore } = await import('pinia')
const { ref } = await import('vue')
return {
useCanvasStore: defineStore('canvasStoreMock', () => {
const selectedItems = ref<unknown[]>([])
const currentGraph = ref(undefined)
const canvas = { setDirty: vi.fn() }
return { canvas, currentGraph, selectedItems }
})
}
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return mockActiveWorkflow.value
}
})
}))
vi.mock('@/stores/workspace/rightSidePanelStore', async () => {
const { defineStore } = await import('pinia')
const { ref } = await import('vue')
type ActiveTab =
| 'errors'
| 'parameters'
| 'nodes'
| 'info'
| 'settings'
| 'subgraph'
return {
useRightSidePanelStore: defineStore('rightSidePanelStoreMock', () => {
const isOpen = ref(true)
const activeTab = ref<ActiveTab>('parameters')
const isEditingSubgraph = ref(false)
const closePanel = vi.fn()
const openPanel = vi.fn()
return { isOpen, activeTab, isEditingSubgraph, closePanel, openPanel }
})
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Sidebar.Location') return mockSidebarLocation.value
if (key === 'Comfy.RightSidePanel.ShowErrorsTab') return false
return undefined
})
})
}))
vi.mock('@/stores/executionErrorStore', async () => {
const { defineStore } = await import('pinia')
const { ref } = await import('vue')
return {
useExecutionErrorStore: defineStore('executionErrorStoreMock', () => {
const hasAnyError = ref(false)
const allErrorExecutionIds = ref<string[]>([])
const activeGraphErrorNodeIds = ref(new Set<string>())
return {
hasAnyError,
allErrorExecutionIds,
activeGraphErrorNodeIds,
isContainerWithInternalError: vi.fn(() => false)
}
})
}
})
vi.mock('@/platform/missingModel/missingModelStore', async () => {
const { defineStore } = await import('pinia')
const { ref } = await import('vue')
return {
useMissingModelStore: defineStore('missingModelStoreMock', () => {
const activeMissingModelGraphIds = ref(new Set<string>())
return { activeMissingModelGraphIds }
})
}
})
vi.mock('@/platform/missingMedia/missingMediaStore', async () => {
const { defineStore } = await import('pinia')
const { ref } = await import('vue')
return {
useMissingMediaStore: defineStore('missingMediaStoreMock', () => {
const activeMissingMediaGraphIds = ref(new Set<string>())
return { activeMissingMediaGraphIds }
})
}
})
vi.mock('@/platform/nodeReplacement/missingNodesErrorStore', () => ({
useMissingNodesErrorStore: () => ({
missingAncestorExecutionIds: []
})
}))
vi.mock('@/composables/graph/useGraphHierarchy', () => ({
useGraphHierarchy: () => ({
findParentGroup: vi.fn(() => null)
})
}))
vi.mock('@/scripts/app', () => ({
app: {
isGraphReady: false,
rootGraph: { nodes: [] }
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getActiveGraphNodeIds: vi.fn(() => new Set<string>())
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: vi.fn(() => 'Mock Node')
}))
vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphGroup: vi.fn(() => false),
isLGraphNode: vi.fn(
(item: unknown) =>
typeof item === 'object' && item !== null && 'isSubgraphNode' in item
)
}))
vi.mock('@/i18n', () => ({
st: vi.fn((key: string) => key)
}))
vi.mock(import('@/lib/litegraph/src/litegraph'), async (importOriginal) => {
const actual = await importOriginal()
class SubgraphNode {}
return {
...actual,
SubgraphNode: SubgraphNode as unknown as typeof actual.SubgraphNode
}
})
const mountCounters = {
tabNodes: 0,
tabNormalInputs: 0,
tabSubgraphInputs: 0,
subgraphEditor: 0
}
function makeMountTracker(key: keyof typeof mountCounters, testid: string) {
return defineComponent({
setup() {
mountCounters[key]++
const id = mountCounters[key]
return { id }
},
template: `<div data-testid="${testid}" :data-mount-id="id" />`
})
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { settings: 'Settings' },
rightSidePanel: {
errors: 'Errors',
nodes: 'Nodes',
parameters: 'Parameters',
info: 'Info',
workflowOverview: 'Workflow',
title: 'Selection ({count})',
fallbackNodeTitle: 'Untitled',
fallbackGroupTitle: 'Untitled Group',
editTitle: 'Edit',
editSubgraph: 'Edit subgraph',
togglePanel: 'Toggle panel',
globalSettings: { title: 'Global Settings' }
}
}
}
})
const renderOptions = {
global: {
plugins: [i18n],
stubs: {
EditableText: { template: '<span><slot /></span>' },
Tab: { template: '<div><slot /></div>' },
TabList: {
template: '<div><slot /></div>',
emits: ['update:modelValue']
},
Button: { template: '<button><slot /></button>' },
TabErrors: { template: '<div data-testid="tab-errors" />' },
TabGlobalParameters: {
template: '<div data-testid="tab-global-parameters" />'
},
TabNodes: makeMountTracker('tabNodes', 'tab-nodes'),
TabGlobalSettings: {
template: '<div data-testid="tab-global-settings" />'
},
TabSubgraphInputs: makeMountTracker(
'tabSubgraphInputs',
'tab-subgraph-inputs'
),
TabNormalInputs: makeMountTracker('tabNormalInputs', 'tab-normal-inputs'),
TabInfo: { template: '<div data-testid="tab-info" />' },
TabSettings: { template: '<div data-testid="tab-settings" />' },
SubgraphEditor: makeMountTracker('subgraphEditor', 'subgraph-editor')
}
}
}
describe('RightSidePanel', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let rightSidePanelStore: ReturnType<typeof useRightSidePanelStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
canvasStore = useCanvasStore()
rightSidePanelStore = useRightSidePanelStore()
canvasStore.selectedItems = []
mockActiveWorkflow.value = { path: 'wf-1' }
rightSidePanelStore.activeTab = 'parameters'
mountCounters.tabNodes = 0
mountCounters.tabNormalInputs = 0
mountCounters.tabSubgraphInputs = 0
mountCounters.subgraphEditor = 0
})
afterEach(() => {
vi.restoreAllMocks()
})
it('remounts TabNodes when the active workflow path changes', async () => {
rightSidePanelStore.activeTab = 'nodes'
render(RightSidePanel, renderOptions)
await nextTick()
const initial = screen
.getByTestId('tab-nodes')
.getAttribute('data-mount-id')
expect(initial).toBeTruthy()
mockActiveWorkflow.value = { path: 'wf-2' }
await nextTick()
await nextTick()
const remounted = screen
.getByTestId('tab-nodes')
.getAttribute('data-mount-id')
expect(remounted).not.toBe(initial)
})
it('does not remount TabNodes when the workflow path stays the same', async () => {
rightSidePanelStore.activeTab = 'nodes'
render(RightSidePanel, renderOptions)
await nextTick()
const initial = screen
.getByTestId('tab-nodes')
.getAttribute('data-mount-id')
mockActiveWorkflow.value = { path: 'wf-1' }
await nextTick()
expect(screen.getByTestId('tab-nodes').getAttribute('data-mount-id')).toBe(
initial
)
})
it('uses an empty workflow key when no workflow is active', async () => {
rightSidePanelStore.activeTab = 'nodes'
mockActiveWorkflow.value = null
render(RightSidePanel, renderOptions)
await nextTick()
expect(screen.getByTestId('tab-nodes')).toBeInTheDocument()
})
it('remounts TabNormalInputs when selection identity changes', async () => {
const node1 = fromAny<LGraphNode, unknown>({
id: 11,
title: 'Node 1',
isSubgraphNode: () => false,
widgets: []
})
const node2 = fromAny<LGraphNode, unknown>({
id: 22,
title: 'Node 2',
isSubgraphNode: () => false,
widgets: []
})
canvasStore.selectedItems = [node1]
rightSidePanelStore.activeTab = 'parameters'
render(RightSidePanel, renderOptions)
await nextTick()
await nextTick()
const initial = screen
.getByTestId('tab-normal-inputs')
.getAttribute('data-mount-id')
expect(initial).toBeTruthy()
canvasStore.selectedItems = [node2]
await nextTick()
await nextTick()
const remounted = screen
.getByTestId('tab-normal-inputs')
.getAttribute('data-mount-id')
expect(remounted).not.toBe(initial)
})
it('remounts TabNormalInputs when the workflow path changes (selectedNodesKey embeds workflowKey)', async () => {
const node = fromAny<LGraphNode, unknown>({
id: 11,
title: 'Node 1',
isSubgraphNode: () => false,
widgets: []
})
canvasStore.selectedItems = [node]
rightSidePanelStore.activeTab = 'parameters'
render(RightSidePanel, renderOptions)
await nextTick()
await nextTick()
const initial = screen
.getByTestId('tab-normal-inputs')
.getAttribute('data-mount-id')
mockActiveWorkflow.value = { path: 'wf-2' }
await nextTick()
await nextTick()
const remounted = screen
.getByTestId('tab-normal-inputs')
.getAttribute('data-mount-id')
expect(remounted).not.toBe(initial)
})
})

View File

@@ -14,6 +14,7 @@ import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
@@ -40,6 +41,7 @@ import SubgraphEditor from './subgraph/SubgraphEditor.vue'
import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingMediaStore = useMissingMediaStore()
@@ -105,6 +107,14 @@ const isSingleSubgraphNode = computed(() => {
return selectedSingleNode.value instanceof SubgraphNode
})
const selectedNodesKey = computed(() => {
const nodeIds = selectedNodes.value.map((n) => n.id).join(',')
const groupIds = selectedGroups.value.map((g) => g.id).join(',')
return `${workflowKey.value}|${nodeIds}|${groupIds}`
})
const workflowKey = computed(() => workflowStore.activeWorkflow?.path ?? '')
function closePanel() {
rightSidePanelStore.closePanel()
}
@@ -374,20 +384,23 @@ function handleTitleCancel() {
<TabErrors v-if="activeTab === 'errors'" />
<template v-else-if="!hasSelection">
<TabGlobalParameters v-if="activeTab === 'parameters'" />
<TabNodes v-else-if="activeTab === 'nodes'" />
<TabNodes v-else-if="activeTab === 'nodes'" :key="workflowKey" />
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
</template>
<SubgraphEditor
v-else-if="isSingleSubgraphNode && isEditingSubgraph"
:key="`${workflowKey}:${selectedSingleNode!.id}`"
:node="selectedSingleNode"
/>
<template v-else>
<TabSubgraphInputs
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
:key="`${workflowKey}:${selectedSingleNode!.id}`"
:node="selectedSingleNode as SubgraphNode"
/>
<TabNormalInputs
v-else-if="activeTab === 'parameters'"
:key="selectedNodesKey"
:nodes="selectedNodes"
:must-show-node-title="selectedGroups.length > 0"
/>

View File

@@ -0,0 +1,166 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import TabGlobalParameters from './TabGlobalParameters.vue'
const mockValidFavoritedWidgets = ref([
{
node: { id: 1, title: 'Node A' },
widget: {
name: 'alpha-widget',
type: 'text',
value: 'alpha',
options: {}
}
},
{
node: { id: 2, title: 'Node B' },
widget: {
name: 'beta-widget',
type: 'text',
value: 'beta',
options: {}
}
}
])
const mockReorderFavorites = vi.fn()
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
useFavoritedWidgetsStore: () => ({
validFavoritedWidgets: mockValidFavoritedWidgets.value,
reorderFavorites: mockReorderFavorites
})
}))
vi.mock('@/scripts/ui/draggableList', () => ({
DraggableList: vi.fn().mockImplementation(() => ({ dispose: vi.fn() }))
}))
const AsyncSearchInputStub = defineComponent({
props: {
modelValue: { type: String, default: '' },
searcher: { type: Function, default: undefined }
},
emits: ['update:modelValue'],
setup(props, { emit }) {
function onInput(event: Event) {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value)
props.searcher?.(value)
}
return { onInput }
},
template:
'<input data-testid="search-input" :value="modelValue" @input="onInput" />'
})
const SectionWidgetsStub = defineComponent({
props: {
widgets: { type: Array, default: () => [] }
},
template: `
<div>
<ul>
<li
v-for="item in widgets"
:key="item.widget.name"
data-testid="widget-name"
>
{{ item.widget.name }}
</li>
</ul>
<slot v-if="widgets.length === 0" name="empty" />
</div>
`
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
favorites: 'Favorites',
favoritesNone: 'No favorites',
noneSearchDesc: 'No results found',
favoritesNoneDesc: 'Add favorites to get started',
favoritesNoneHint: 'Use the <moreIcon/> menu to favorite widgets'
}
}
}
})
function renderComponent() {
const user = userEvent.setup()
const result = render(TabGlobalParameters, {
global: {
plugins: [i18n],
stubs: {
AsyncSearchInput: AsyncSearchInputStub,
SectionWidgets: SectionWidgetsStub
}
}
})
return { user, ...result }
}
describe('TabGlobalParameters', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
it('starts with empty search query and filters widgets as user types', async () => {
const { user } = renderComponent()
expect(screen.getByTestId('search-input')).toHaveValue('')
expect(screen.getByText('alpha-widget')).toBeInTheDocument()
expect(screen.getByText('beta-widget')).toBeInTheDocument()
await user.type(screen.getByTestId('search-input'), 'alpha-widget')
expect(screen.getByTestId('search-input')).toHaveValue('alpha-widget')
expect(screen.getByText('alpha-widget')).toBeInTheDocument()
expect(screen.queryByText('beta-widget')).not.toBeInTheDocument()
})
it('resets local search query after remount via key change', async () => {
const user = userEvent.setup()
const Wrapper = defineComponent({
components: { TabGlobalParameters },
props: {
k: { type: String, required: true }
},
template: '<TabGlobalParameters :key="k" />'
})
const { rerender } = render(Wrapper, {
props: { k: 'ctx-1' },
global: {
plugins: [i18n],
stubs: {
AsyncSearchInput: AsyncSearchInputStub,
SectionWidgets: SectionWidgetsStub
}
}
})
await user.type(screen.getByTestId('search-input'), 'beta-widget')
expect(screen.getByTestId('search-input')).toHaveValue('beta-widget')
await rerender({ k: 'ctx-2' })
expect(screen.getByTestId('search-input')).toHaveValue('')
expect(screen.getByText('alpha-widget')).toBeInTheDocument()
expect(screen.getByText('beta-widget')).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { useMounted, watchDebounced } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import {
computed,
nextTick,
@@ -15,14 +14,12 @@ import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgets } from '../shared'
import SectionWidgets from './SectionWidgets.vue'
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const searchQuery = ref('')
const { t } = useI18n()
const draggableList = ref<DraggableList | undefined>(undefined)

View File

@@ -0,0 +1,177 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import TabNodes from './TabNodes.vue'
const mockNodes = [
fromAny<LGraphNode, unknown>({
id: 11,
title: 'Alpha Node',
getTitle: () => 'Alpha Node',
widgets: [
{
name: 'alpha-widget',
type: 'text',
value: 'alpha',
options: {}
}
]
}),
fromAny<LGraphNode, unknown>({
id: 22,
title: 'Beta Node',
getTitle: () => 'Beta Node',
widgets: [
{
name: 'beta-widget',
type: 'text',
value: 'beta',
options: {}
}
]
})
]
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => false)
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: {
graph: {
nodes: mockNodes
}
}
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { path: 'workflow-a' }
})
}))
const AsyncSearchInputStub = defineComponent({
props: {
modelValue: { type: String, default: '' },
searcher: { type: Function, default: undefined }
},
emits: ['update:modelValue'],
setup(props, { emit }) {
function onInput(event: Event) {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value)
props.searcher?.(value)
}
return { onInput }
},
template:
'<input data-testid="search-input" :value="modelValue" @input="onInput" />'
})
const SectionWidgetsStub = defineComponent({
props: {
node: { type: Object, required: true },
widgets: { type: Array, default: () => [] }
},
template: `
<div data-testid="section-item">
<span data-testid="node-title">{{ node.title }}</span>
<span
v-for="item in widgets"
:key="item.widget.name"
data-testid="widget-name"
>
{{ item.widget.name }}
</span>
</div>
`
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
noneSearchDesc: 'No results found',
inputsNoneTooltip: 'No inputs'
}
}
}
})
describe('TabNodes', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
it('starts with an empty search and filters node widgets', async () => {
const user = userEvent.setup()
render(TabNodes, {
global: {
plugins: [i18n],
stubs: {
AsyncSearchInput: AsyncSearchInputStub,
SectionWidgets: SectionWidgetsStub,
CollapseToggleButton: { template: '<div />' }
}
}
})
expect(screen.getByTestId('search-input')).toHaveValue('')
expect(screen.getByText('alpha-widget')).toBeInTheDocument()
expect(screen.getByText('beta-widget')).toBeInTheDocument()
await user.type(screen.getByTestId('search-input'), 'alpha-widget')
expect(screen.getByTestId('search-input')).toHaveValue('alpha-widget')
expect(screen.getByText('alpha-widget')).toBeInTheDocument()
expect(screen.queryByText('beta-widget')).not.toBeInTheDocument()
})
it('creates fresh local search state after key-driven remount', async () => {
const user = userEvent.setup()
const Wrapper = defineComponent({
components: { TabNodes },
props: {
k: { type: String, required: true }
},
template: '<TabNodes :key="k" />'
})
const { rerender } = render(Wrapper, {
props: { k: 'ctx-1' },
global: {
plugins: [i18n],
stubs: {
AsyncSearchInput: AsyncSearchInputStub,
SectionWidgets: SectionWidgetsStub,
CollapseToggleButton: { template: '<div />' }
}
}
})
await user.type(screen.getByTestId('search-input'), 'beta-widget')
expect(screen.getByTestId('search-input')).toHaveValue('beta-widget')
await rerender({ k: 'ctx-2' })
expect(screen.getByTestId('search-input')).toHaveValue('')
expect(screen.getByText('alpha-widget')).toBeInTheDocument()
expect(screen.getByText('beta-widget')).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { computed, reactive, ref, shallowRef } from 'vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
@@ -8,7 +7,6 @@ import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
import type { NodeWidgetsListList } from '../shared'
@@ -23,8 +21,7 @@ const nodes = computed((): LGraphNode[] => {
return (canvasStore.canvas?.graph?.nodes ?? []) as LGraphNode[]
})
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const searchQuery = ref('')
const { widgetsSectionDataList } = computedSectionDataList(nodes)
@@ -35,15 +32,6 @@ const isSearching = ref(false)
const collapseMap = reactive<Record<string, boolean>>({})
watch(
() => workflowStore.activeWorkflow?.path,
() => {
for (const key of Object.keys(collapseMap)) {
delete collapseMap[key]
}
}
)
function isSectionCollapsed(nodeId: NodeId): boolean {
// Defaults to collapsed when not explicitly set by the user
return collapseMap[nodeId] ?? true

View File

@@ -0,0 +1,171 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import TabNormalInputs from './TabNormalInputs.vue'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => false)
})
}))
const AsyncSearchInputStub = defineComponent({
props: {
modelValue: { type: String, default: '' },
searcher: { type: Function, default: undefined }
},
emits: ['update:modelValue'],
setup(props, { emit }) {
function onInput(event: Event) {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value)
props.searcher?.(value)
}
return { onInput }
},
template:
'<input data-testid="search-input" :value="modelValue" @input="onInput" />'
})
const SectionWidgetsStub = defineComponent({
props: {
node: { type: Object, default: undefined },
widgets: { type: Array, default: () => [] }
},
template: `
<div data-testid="section-item">
<span v-if="node" data-testid="node-title">{{ node.title }}</span>
<span
v-for="item in widgets"
:key="item.widget.name"
data-testid="widget-name"
>
{{ item.widget.name }}
</span>
</div>
`
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
noneSearchDesc: 'No results found',
nodesNoneDesc: 'No nodes selected',
inputs: 'Inputs',
inputsNone: 'No inputs',
advancedInputs: 'Advanced Inputs',
inputsNoneTooltip: 'No inputs available'
}
}
}
})
function createNode(
id: number,
title: string,
widgetName: string,
options: { advanced?: boolean } = {}
): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
title,
getTitle: () => title,
widgets: [
{
name: widgetName,
type: 'text',
value: widgetName,
options
}
]
})
}
const testNodes = [
createNode(1, 'Alpha Node', 'alpha-widget'),
createNode(2, 'Beta Node', 'beta-widget')
]
describe('TabNormalInputs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
it('starts with empty search query and filters widgets from provided nodes', async () => {
const user = userEvent.setup()
render(TabNormalInputs, {
props: {
nodes: testNodes
},
global: {
plugins: [i18n],
stubs: {
AsyncSearchInput: AsyncSearchInputStub,
SectionWidgets: SectionWidgetsStub,
CollapseToggleButton: { template: '<div />' }
}
}
})
expect(screen.getByTestId('search-input')).toHaveValue('')
expect(screen.getByText('alpha-widget')).toBeInTheDocument()
expect(screen.getByText('beta-widget')).toBeInTheDocument()
await user.type(screen.getByTestId('search-input'), 'alpha-widget')
expect(screen.getByTestId('search-input')).toHaveValue('alpha-widget')
expect(screen.getByText('alpha-widget')).toBeInTheDocument()
expect(screen.queryByText('beta-widget')).not.toBeInTheDocument()
})
it('resets local search query after remount with a new key', async () => {
const user = userEvent.setup()
const Wrapper = defineComponent({
components: { TabNormalInputs },
props: {
k: { type: String, required: true }
},
setup() {
return {
nodes: testNodes
}
},
template: '<TabNormalInputs :key="k" :nodes="nodes" />'
})
const { rerender } = render(Wrapper, {
props: { k: 'ctx-1' },
global: {
plugins: [i18n],
stubs: {
AsyncSearchInput: AsyncSearchInputStub,
SectionWidgets: SectionWidgetsStub,
CollapseToggleButton: { template: '<div />' }
}
}
})
await user.type(screen.getByTestId('search-input'), 'beta-widget')
expect(screen.getByTestId('search-input')).toHaveValue('beta-widget')
await rerender({ k: 'ctx-2' })
expect(screen.getByTestId('search-input')).toHaveValue('')
expect(screen.getByText('alpha-widget')).toBeInTheDocument()
expect(screen.getByText('beta-widget')).toBeInTheDocument()
})
})

View File

@@ -1,13 +1,10 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { computed, reactive, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
import type { NodeWidgetsListList } from '../shared'
@@ -19,10 +16,7 @@ const { nodes, mustShowNodeTitle } = defineProps<{
}>()
const { t } = useI18n()
const workflowStore = useWorkflowStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const searchQuery = ref('')
const { widgetsSectionDataList, includesAdvanced } = computedSectionDataList(
() => nodes
@@ -58,16 +52,6 @@ const isSearching = ref(false)
const collapseMap = reactive<Record<string, boolean>>({})
const advancedCollapsed = ref(true)
watch(
() => workflowStore.activeWorkflow?.path,
() => {
for (const key of Object.keys(collapseMap)) {
delete collapseMap[key]
}
advancedCollapsed.value = true
}
)
function isSectionCollapsed(nodeId: NodeId): boolean {
// When not explicitly set, sections are collapsed if multiple nodes are selected
return collapseMap[nodeId] ?? isMultipleNodesSelected.value

View File

@@ -0,0 +1,204 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import TabSubgraphInputs from './TabSubgraphInputs.vue'
vi.mock('@/core/graph/subgraph/promotedWidgetTypes', () => ({
isPromotedWidgetView: vi.fn(() => false)
}))
vi.mock('@/core/graph/subgraph/promotionUtils', () => ({
getWidgetName: vi.fn((widget) => widget.name)
}))
vi.mock('@/stores/promotionStore', () => ({
usePromotionStore: () => ({
getPromotions: vi.fn(() => [
{
sourceNodeId: '11',
sourceWidgetName: 'alpha-widget',
disambiguatingSourceNodeId: undefined
},
{
sourceNodeId: '22',
sourceWidgetName: 'beta-widget',
disambiguatingSourceNodeId: undefined
}
]),
isPromoted: vi.fn(() => false),
movePromotion: vi.fn()
})
}))
vi.mock('@/scripts/ui/draggableList', () => ({
DraggableList: vi.fn().mockImplementation(() => ({ dispose: vi.fn() }))
}))
const AsyncSearchInputStub = defineComponent({
props: {
modelValue: { type: String, default: '' },
searcher: { type: Function, default: undefined }
},
emits: ['update:modelValue'],
setup(props, { emit }) {
function onInput(event: Event) {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value)
props.searcher?.(value)
}
return { onInput }
},
template:
'<input data-testid="search-input" :value="modelValue" @input="onInput" />'
})
const SectionWidgetsStub = defineComponent({
props: {
widgets: { type: Array, default: () => [] }
},
template: `
<div>
<span
v-for="item in widgets"
:key="item.widget.name"
data-testid="widget-name"
>
{{ item.widget.name }}
</span>
<slot v-if="widgets.length === 0" name="empty" />
</div>
`
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
inputs: 'Inputs',
inputsNone: 'No inputs',
advancedInputs: 'Advanced Inputs',
noneSearchDesc: 'No results found',
inputsNoneTooltip: 'No inputs available'
}
}
}
})
const subgraphNode = fromAny<SubgraphNode, unknown>({
id: 100,
rootGraph: { id: 'root-1' },
widgets: [
{
name: 'alpha-widget',
type: 'text',
value: 'alpha',
options: {}
},
{
name: 'beta-widget',
type: 'text',
value: 'beta',
options: {}
}
],
subgraph: {
nodes: [
{
id: 11,
title: 'Interior 1',
widgets: [
{
name: 'interior-widget',
type: 'text',
value: 'interior',
computedDisabled: false,
options: {}
}
]
}
]
}
})
describe('TabSubgraphInputs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }))
vi.clearAllMocks()
})
it('starts with an empty search and filters promoted widgets', async () => {
const user = userEvent.setup()
render(TabSubgraphInputs, {
props: {
node: subgraphNode
},
global: {
plugins: [i18n],
stubs: {
AsyncSearchInput: AsyncSearchInputStub,
SectionWidgets: SectionWidgetsStub,
CollapseToggleButton: { template: '<div />' }
}
}
})
expect(screen.getByTestId('search-input')).toHaveValue('')
expect(screen.getByText('alpha-widget')).toBeInTheDocument()
expect(screen.getByText('beta-widget')).toBeInTheDocument()
await user.type(screen.getByTestId('search-input'), 'alpha-widget')
expect(screen.getByTestId('search-input')).toHaveValue('alpha-widget')
expect(screen.getByText('alpha-widget')).toBeInTheDocument()
expect(screen.queryByText('beta-widget')).not.toBeInTheDocument()
})
it('resets local search query after key-based remount', async () => {
const user = userEvent.setup()
const Wrapper = defineComponent({
components: { TabSubgraphInputs },
props: {
k: { type: String, required: true }
},
setup() {
return {
node: subgraphNode
}
},
template: '<TabSubgraphInputs :key="k" :node="node" />'
})
const { rerender } = render(Wrapper, {
props: { k: 'ctx-1' },
global: {
plugins: [i18n],
stubs: {
AsyncSearchInput: AsyncSearchInputStub,
SectionWidgets: SectionWidgetsStub,
CollapseToggleButton: { template: '<div />' }
}
}
})
await user.type(screen.getByTestId('search-input'), 'beta-widget')
expect(screen.getByTestId('search-input')).toHaveValue('beta-widget')
await rerender({ k: 'ctx-2' })
expect(screen.getByTestId('search-input')).toHaveValue('')
expect(screen.getByText('alpha-widget')).toBeInTheDocument()
expect(screen.getByText('beta-widget')).toBeInTheDocument()
})
})

View File

@@ -35,7 +35,8 @@ const { t } = useI18n()
const canvasStore = useCanvasStore()
const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
const { focusedSection } = storeToRefs(rightSidePanelStore)
const searchQuery = ref('')
const advancedInputsCollapsed = ref(true)
const firstSectionCollapsed = ref(false)

View File

@@ -5,21 +5,21 @@ import { describe, expect, it, beforeEach } from 'vitest'
import { flatAndCategorizeSelectedItems, searchWidgets } from './shared'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
describe('searchWidgets', () => {
const createWidget = (
name: string,
type: string,
value?: string,
label?: string
): { widget: IBaseWidget } => ({
widget: {
name,
type,
value,
label
} as IBaseWidget
})
const createWidget = (
name: string,
type: string,
value?: string,
label?: string
): { widget: IBaseWidget } => ({
widget: {
name,
type,
value,
label
} as IBaseWidget
})
describe('searchWidgets', () => {
it('should return all widgets when query is empty', () => {
const widgets = [
createWidget('width', 'number', '100'),
@@ -71,6 +71,22 @@ describe('searchWidgets', () => {
})
})
describe('searchWidgets — context independence', () => {
it('returns empty when query from previous context has no matches in new context', () => {
const newWidgets = [createWidget('strength', 'slider', '0.8')]
const result = searchWidgets(newWidgets, 'seed')
expect(result).toHaveLength(0)
})
it('returns all widgets when fresh empty query is applied', () => {
const widgets = [
createWidget('strength', 'slider', '0.8'),
createWidget('steps', 'number', '20')
]
expect(searchWidgets(widgets, '')).toEqual(widgets)
})
})
describe('flatAndCategorizeSelectedItems', () => {
let testGroup1: LGraphGroup
let testGroup2: LGraphGroup

View File

@@ -0,0 +1,224 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { SubgraphNode as RuntimeSubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import SubgraphEditor from './SubgraphEditor.vue'
const mockSelectedItems = ref<unknown[]>([])
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: { setDirty: vi.fn() },
get selectedItems() {
return mockSelectedItems.value
}
})
}))
vi.mock('@/stores/promotionStore', () => ({
usePromotionStore: () => ({
getPromotions: vi.fn(() => []),
setPromotions: vi.fn(),
isPromoted: vi.fn(() => false),
movePromotion: vi.fn()
})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({
updatePreviews: vi.fn()
})
}))
vi.mock('@/core/graph/subgraph/promotedWidgetTypes', () => ({
isPromotedWidgetView: vi.fn(() => false)
}))
vi.mock('@/core/graph/subgraph/promotionUtils', () => ({
demoteWidget: vi.fn(),
getPromotableWidgets: vi.fn((node: { widgets?: unknown[] }) =>
Array.isArray(node?.widgets) ? node.widgets : []
),
getSourceNodeId: vi.fn(() => undefined),
getWidgetName: vi.fn((widget: { name: string }) => widget.name),
isLinkedPromotion: vi.fn(() => false),
isRecommendedWidget: vi.fn(() => false),
promoteWidget: vi.fn(),
pruneDisconnected: vi.fn()
}))
vi.mock('@/lib/litegraph/src/subgraph/SubgraphNode', () => {
class SubgraphNode {}
return { SubgraphNode }
})
const AsyncSearchInputStub = defineComponent({
props: {
modelValue: { type: String, default: '' }
},
emits: ['update:modelValue'],
setup(_, { emit }) {
function onInput(event: Event) {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
return { onInput }
},
template:
'<input data-testid="search-input" :value="modelValue" @input="onInput" />'
})
const SubgraphNodeWidgetStub = defineComponent({
props: {
nodeTitle: { type: String, default: '' },
widgetName: { type: String, default: '' }
},
template:
'<div data-testid="subgraph-widget">{{ nodeTitle }}::{{ widgetName }}</div>'
})
const DraggableListStub = defineComponent({
template: '<div><slot :dragClass="\'\'" /></div>'
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
noneSearchDesc: 'No results found'
},
subgraphStore: {
linked: 'Linked',
shown: 'Shown',
hidden: 'Hidden',
showAll: 'Show all',
hideAll: 'Hide all',
showRecommended: 'Show recommended'
}
}
}
})
function makeSubgraphNode(): SubgraphNode {
const interiorAlpha = fromAny({
id: 11,
title: 'Alpha Interior',
widgets: [
{
name: 'alpha-widget',
type: 'text',
value: 'alpha',
computedDisabled: false,
options: {}
}
],
updateComputedDisabled: () => undefined
})
const interiorBeta = fromAny({
id: 22,
title: 'Beta Interior',
widgets: [
{
name: 'beta-widget',
type: 'text',
value: 'beta',
computedDisabled: false,
options: {}
}
],
updateComputedDisabled: () => undefined
})
const NodeCtor = RuntimeSubgraphNode as unknown as new () => SubgraphNode
return fromAny<SubgraphNode, unknown>(
Object.assign(new NodeCtor(), {
id: 100,
rootGraph: { id: 'root-1' },
widgets: [],
subgraph: {
_nodes_by_id: { '11': interiorAlpha, '22': interiorBeta },
nodes: [interiorAlpha, interiorBeta]
},
computeSize: vi.fn(),
setDirtyCanvas: vi.fn()
})
)
}
const renderOptions = {
global: {
plugins: [i18n],
stubs: {
AsyncSearchInput: AsyncSearchInputStub,
SubgraphNodeWidget: SubgraphNodeWidgetStub,
DraggableList: DraggableListStub,
Button: { template: '<button v-bind="$attrs"><slot /></button>' }
}
}
}
describe('SubgraphEditor', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
mockSelectedItems.value = [makeSubgraphNode()]
})
it('starts with an empty search query and renders candidate widgets', () => {
render(SubgraphEditor, renderOptions)
expect(screen.getByTestId('search-input')).toHaveValue('')
expect(screen.getByText('Alpha Interior::alpha-widget')).toBeInTheDocument()
expect(screen.getByText('Beta Interior::beta-widget')).toBeInTheDocument()
})
it('filters candidate widgets when the user types into the search', async () => {
const user = userEvent.setup()
render(SubgraphEditor, renderOptions)
await user.type(screen.getByTestId('search-input'), 'alpha')
expect(screen.getByTestId('search-input')).toHaveValue('alpha')
expect(screen.getByText('Alpha Interior::alpha-widget')).toBeInTheDocument()
expect(
screen.queryByText('Beta Interior::beta-widget')
).not.toBeInTheDocument()
})
it('resets local search query after a key-based remount', async () => {
const user = userEvent.setup()
const Wrapper = defineComponent({
components: { SubgraphEditor },
props: {
k: { type: String, required: true }
},
template: '<SubgraphEditor :key="k" />'
})
const { rerender } = render(Wrapper, {
props: { k: 'ctx-1' },
...renderOptions
})
await user.type(screen.getByTestId('search-input'), 'alpha')
expect(screen.getByTestId('search-input')).toHaveValue('alpha')
expect(
screen.queryByText('Beta Interior::beta-widget')
).not.toBeInTheDocument()
await rerender({ k: 'ctx-2' })
expect(screen.getByTestId('search-input')).toHaveValue('')
expect(screen.getByText('Alpha Interior::alpha-widget')).toBeInTheDocument()
expect(screen.getByText('Beta Interior::beta-widget')).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
@@ -23,7 +22,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { cn } from '@comfyorg/tailwind-utils'
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
@@ -31,8 +29,7 @@ import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
const { t } = useI18n()
const canvasStore = useCanvasStore()
const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const searchQuery = ref('')
const promotionEntries = computed(() => {
const node = activeNode.value

View File

@@ -39,7 +39,6 @@ export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
* cleared by TabErrors after expanding the relevant error group.
*/
const focusedErrorNodeId = ref<string | null>(null)
const searchQuery = ref('')
// Auto-close panel when switching to legacy menu mode
watch(isLegacyMenu, (legacy) => {
@@ -87,7 +86,6 @@ export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
isEditingSubgraph,
focusedSection,
focusedErrorNodeId,
searchQuery,
openPanel,
closePanel,
togglePanel,