Compare commits

...

7 Commits

Author SHA1 Message Date
bymyself
d46644277d test: assert expanded-key state in folder/search V2 sidebar tests
Address coderabbit r3177849972: tests previously asserted only mock calls
and could pass even if expand/collapse logic regressed. Expose expandedKeys
in the AllNodesPanel mock and assert real expand/collapse/clear behavior.
2026-05-03 21:55:51 -07:00
bymyself
329f7de20b test: lift NodeLibrarySidebarTabV2 unit-test coverage above 80%
Adds tests covering tab switching, node-click drag dispatch, folder
expand/collapse toggling, and search-trigger handling so the V2
sidebar's diff-line coverage clears the 80% threshold (was 47.74%
lines / 41.30% statements; now 82.58% / 75.00%).

Refactors mocks to use `vi.hoisted` for store stubs that need shared
references between the factory and tests.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11413#discussion_r3107691219
2026-05-03 21:53:53 -07:00
bymyself
07d86eff35 test: route V2 node-help assertions through sidebar fixture
Adds `nodeHelpContent` and `helpBackButton` to NodeLibrarySidebarTabV2
and audits the V2 spec block to use the existing
`comfyPage.menu.nodeLibraryTabV2` page object instead of raw
`.sidebar-content-container` queries (and the inline back-button regex).

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11413#discussion_r3107693602
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11413#discussion_r3107694246
2026-05-03 21:53:53 -07:00
bymyself
a24a9a4dff test: use contextMenu fixture entries for Node Info lookup
Replace the raw '.p-contextmenu' query with the existing
`comfyPage.contextMenu` page object. Adds an `entries` Locator and a
`getEntry(name)` helper to ContextMenu; `menuItems` is retained as a
deprecated alias to avoid breaking other tests.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11413#discussion_r3107692493
2026-05-03 21:53:53 -07:00
bymyself
aa32844aea test: drop redundant nextFrame after more-options click
The next assertion (`expect(menu.getByText('Node Info')).toBeVisible()`)
already auto-retries until the menu renders, so the explicit frame wait
adds nothing.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11413#discussion_r3107692811
2026-05-03 21:53:24 -07:00
bymyself
34e8fc8915 test: scope more-options-button locator to selectionToolbox
Use `comfyPage.selectionToolbox.getByTestId(...)` instead of
querying from the top-level page scope. This restricts the search to
the selection toolbox subtree and avoids cross-context matches.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11413#discussion_r3107693133
2026-05-03 21:53:24 -07:00
Glary-Bot
0b5e8c8fdc fix: add nodeHelpStore integration to V2 node library sidebar
NodeLibrarySidebarTabV2 was built without nodeHelpStore integration,
causing 'Node Info' to open the generic node list instead of showing
the selected node's help page. This mirrors the V1 pattern of
conditionally rendering NodeHelpPage when isHelpOpen is true.

Fixes #9996
2026-05-03 21:53:24 -07:00
5 changed files with 547 additions and 159 deletions

View File

@@ -5,21 +5,30 @@ export class ContextMenu {
public readonly primeVueMenu: Locator
public readonly litegraphMenu: Locator
public readonly litegraphContextMenu: Locator
public readonly menuItems: Locator
public readonly entries: Locator
constructor(public readonly page: Page) {
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
this.litegraphMenu = page.locator('.litemenu')
this.litegraphContextMenu = page.locator('.litecontextmenu')
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
this.entries = page.locator('.p-menuitem, .litemenu-entry')
}
/** @deprecated Use {@link entries} instead. */
get menuItems(): Locator {
return this.entries
}
getEntry(name: string, options: { exact?: boolean } = {}): Locator {
return this.page.getByRole('menuitem', { name, exact: options.exact })
}
async clickMenuItem(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name }).click()
await this.getEntry(name).click()
}
async clickMenuItemExact(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name, exact: true }).click()
await this.getEntry(name, { exact: true }).click()
}
/**

View File

@@ -95,6 +95,8 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly allTab: Locator
public readonly blueprintsTab: Locator
public readonly sortButton: Locator
public readonly nodeHelpContent: Locator
public readonly helpBackButton: Locator
constructor(public override readonly page: Page) {
super(page, 'node-library')
@@ -103,6 +105,10 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
this.nodeHelpContent = this.sidebarContent.locator('.node-help-content')
this.helpBackButton = this.sidebarContent.getByRole('button', {
name: /back/i
})
}
getTab(name: string) {

View File

@@ -85,7 +85,7 @@ async function setLocaleAndWaitForWorkflowReload(
}, locale)
}
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
test.describe('Node Help V1', { tag: ['@slow', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
})
@@ -557,3 +557,94 @@ This is English documentation.
})
})
})
test.describe('Node Help V2 Sidebar', { tag: ['@slow', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
})
async function openNodeInfoViMoreOptions(comfyPage: ComfyPage) {
await expect(comfyPage.selectionToolbox).toBeVisible()
const moreOptionsBtn = comfyPage.selectionToolbox.getByTestId(
'more-options-button'
)
await expect(moreOptionsBtn).toBeVisible()
await moreOptionsBtn.click()
const nodeInfoEntry = comfyPage.contextMenu.getEntry('Node Info', {
exact: true
})
await expect(nodeInfoEntry).toBeVisible()
await nodeInfoEntry.click()
await comfyPage.nextFrame()
}
test('Should open node help in V2 sidebar when clicking Node Info', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found in the workflow')
}
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
await openNodeInfoViMoreOptions(comfyPage)
const sidebar = comfyPage.menu.nodeLibraryTabV2
await expect(sidebar.sidebarContent).toBeVisible()
await expect(sidebar.nodeHelpContent).toBeVisible()
await expect(sidebar.sidebarContent).toContainText('KSampler')
await expect(sidebar.searchInput).toBeHidden()
})
test('Should return to V2 node library when clicking back from help page', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found in the workflow')
}
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
await openNodeInfoViMoreOptions(comfyPage)
const sidebar = comfyPage.menu.nodeLibraryTabV2
await expect(sidebar.nodeHelpContent).toBeVisible()
await sidebar.helpBackButton.click()
await expect(sidebar.searchInput).toBeVisible()
await expect(sidebar.nodeHelpContent).toBeHidden()
})
test('Should show correct node info when opening help for different node types', async ({
comfyPage
}) => {
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: '# KSampler Help\n\nKSampler documentation content.'
})
})
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found in the workflow')
}
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
await openNodeInfoViMoreOptions(comfyPage)
const sidebar = comfyPage.menu.nodeLibraryTabV2
await expect(sidebar.sidebarContent).toContainText('KSampler Help')
await expect(sidebar.sidebarContent).toContainText(
'KSampler documentation content'
)
})
})

View File

@@ -1,9 +1,13 @@
import { createTestingPinia } from '@pinia/testing'
import { ref } from 'vue'
import { fromPartial } from '@total-typescript/shoehorn'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import NodeLibrarySidebarTabV2 from './NodeLibrarySidebarTabV2.vue'
@@ -17,12 +21,31 @@ vi.mock('@vueuse/core', async () => {
}
})
const {
mockStartDrag,
mockOrganizeNodesByTab,
mockGetSortingStrategies,
mockSearchNode,
mockVisibleNodeDefs
} = vi.hoisted(() => ({
mockStartDrag: vi.fn(),
mockOrganizeNodesByTab: vi.fn(() => [] as unknown[]),
mockGetSortingStrategies: vi.fn(() => [
{
id: 'alphabetical',
label: 'sideToolbar.nodeLibraryTab.sortByAlphabetical'
}
]),
mockSearchNode: vi.fn(() => [] as unknown[]),
mockVisibleNodeDefs: { value: [] as unknown[] }
}))
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({
isDragging: { value: false },
draggedNode: { value: null },
cursorPosition: { value: { x: 0, y: 0 } },
startDrag: vi.fn(),
startDrag: mockStartDrag,
cancelDrag: vi.fn(),
setupGlobalListeners: vi.fn(),
cleanupGlobalListeners: vi.fn()
@@ -33,16 +56,31 @@ vi.mock('@/services/nodeOrganizationService', () => ({
DEFAULT_TAB_ID: 'essentials',
DEFAULT_SORTING_ID: 'alphabetical',
nodeOrganizationService: {
organizeNodesByTab: vi.fn(() => []),
getSortingStrategies: vi.fn(() => [])
organizeNodesByTab: mockOrganizeNodesByTab,
getSortingStrategies: mockGetSortingStrategies
}
}))
vi.mock('@/stores/nodeDefStore', () => ({
buildNodeDefTree: vi.fn(() => ({ key: 'root', children: [] })),
useNodeDefStore: () => ({
nodeSearchService: { searchNode: mockSearchNode },
visibleNodeDefs: mockVisibleNodeDefs.value
})
}))
vi.mock('./nodeLibrary/AllNodesPanel.vue', () => ({
default: {
name: 'AllNodesPanel',
template: '<div data-testid="all-panel"><slot /></div>',
props: ['sections', 'expandedKeys', 'fillNodeInfo']
template: `
<div data-testid="all-panel">
<div data-testid="expanded-keys">{{ (expandedKeys ?? []).join(',') }}</div>
<button data-testid="all-emit-node" @click="$emit('node-click', { type: 'node', data: { name: 'TestNode' } })">emit-node</button>
<button data-testid="all-emit-folder" @click="$emit('node-click', { type: 'folder', key: 'folder-a' })">emit-folder</button>
</div>
`,
props: ['sections', 'expandedKeys', 'fillNodeInfo', 'sortOrder'],
emits: ['node-click']
}
}))
@@ -50,7 +88,8 @@ vi.mock('./nodeLibrary/BlueprintsPanel.vue', () => ({
default: {
name: 'BlueprintsPanel',
template: '<div data-testid="blueprints-panel"><slot /></div>',
props: ['sections', 'expandedKeys']
props: ['sections', 'expandedKeys'],
emits: ['node-click']
}
}))
@@ -58,7 +97,8 @@ vi.mock('./nodeLibrary/EssentialNodesPanel.vue', () => ({
default: {
name: 'EssentialNodesPanel',
template: '<div data-testid="essential-panel"><slot /></div>',
props: ['root', 'expandedKeys', 'flatNodes']
props: ['root', 'expandedKeys', 'flatNodes'],
emits: ['node-click']
}
}))
@@ -72,8 +112,18 @@ vi.mock('./nodeLibrary/NodeDragPreview.vue', () => ({
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
default: {
name: 'SearchBox',
template: '<input data-testid="search-box" />',
template: `
<div>
<input
data-testid="search-box"
:value="modelValue"
@input="(e) => $emit('update:modelValue', e.target.value)"
/>
<button data-testid="search-trigger" @click="$emit('search')">go</button>
</div>
`,
props: ['modelValue', 'placeholder'],
emits: ['update:modelValue', 'search'],
setup() {
return { focus: vi.fn() }
},
@@ -81,6 +131,29 @@ vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
}
}))
const mockCurrentHelpNode = ref<ComfyNodeDefImpl | null>(null)
const mockIsHelpOpen = ref(false)
const mockCloseHelp = vi.fn()
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
useNodeHelpStore: () => ({
currentHelpNode: mockCurrentHelpNode,
isHelpOpen: mockIsHelpOpen,
openHelp: vi.fn(),
closeHelp: mockCloseHelp
})
}))
vi.mock('./nodeLibrary/NodeHelpPage.vue', () => ({
default: {
name: 'NodeHelpPage',
template:
'<div data-testid="node-help-page">{{ node.display_name }}<button data-testid="help-close-btn" @click="$emit(\'close\')">Close</button></div>',
props: ['node'],
emits: ['close']
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -90,6 +163,10 @@ const i18n = createI18n({
describe('NodeLibrarySidebarTabV2', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentHelpNode.value = null
mockIsHelpOpen.value = false
mockSearchNode.mockReturnValue([])
mockOrganizeNodesByTab.mockReturnValue([])
})
function renderComponent() {
@@ -123,4 +200,193 @@ describe('NodeLibrarySidebarTabV2', () => {
expect(screen.queryByTestId('all-panel')).not.toBeInTheDocument()
expect(screen.queryByTestId('blueprints-panel')).not.toBeInTheDocument()
})
describe('Node Help Integration', () => {
beforeEach(() => {
mockCurrentHelpNode.value = null
mockIsHelpOpen.value = false
})
it('should show node help page when currentHelpNode is set', async () => {
mockCurrentHelpNode.value = fromPartial<ComfyNodeDefImpl>({
name: 'KSampler',
display_name: 'KSampler'
})
mockIsHelpOpen.value = true
renderComponent()
await nextTick()
expect(screen.getByTestId('node-help-page')).toBeInTheDocument()
expect(screen.getByTestId('node-help-page')).toHaveTextContent('KSampler')
expect(screen.queryByTestId('search-box')).not.toBeInTheDocument()
})
it('should show normal node library when currentHelpNode is null', async () => {
renderComponent()
await nextTick()
expect(screen.queryByTestId('node-help-page')).not.toBeInTheDocument()
expect(screen.getByTestId('search-box')).toBeInTheDocument()
})
it('should switch from help to library when closeHelp clears currentHelpNode', async () => {
mockCurrentHelpNode.value = fromPartial<ComfyNodeDefImpl>({
name: 'KSampler',
display_name: 'KSampler'
})
mockIsHelpOpen.value = true
renderComponent()
await nextTick()
expect(screen.getByTestId('node-help-page')).toBeInTheDocument()
mockCurrentHelpNode.value = null
mockIsHelpOpen.value = false
await nextTick()
expect(screen.queryByTestId('node-help-page')).not.toBeInTheDocument()
expect(screen.getByTestId('search-box')).toBeInTheDocument()
})
it('should call closeHelp when NodeHelpPage emits close', async () => {
const user = userEvent.setup()
mockCurrentHelpNode.value = fromPartial<ComfyNodeDefImpl>({
name: 'KSampler',
display_name: 'KSampler'
})
mockIsHelpOpen.value = true
renderComponent()
await nextTick()
await user.click(screen.getByTestId('help-close-btn'))
expect(mockCloseHelp).toHaveBeenCalledOnce()
})
})
describe('Node interaction', () => {
async function selectAllTab(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('tab', { name: /all/i }))
await nextTick()
}
it('should startDrag when a node is clicked in a panel', async () => {
const user = userEvent.setup()
renderComponent()
await selectAllTab(user)
await user.click(screen.getByTestId('all-emit-node'))
expect(mockStartDrag).toHaveBeenCalledWith({ name: 'TestNode' })
})
it('should toggle expanded folder keys when a folder is clicked', async () => {
const user = userEvent.setup()
renderComponent()
await selectAllTab(user)
// First click expands
await user.click(screen.getByTestId('all-emit-folder'))
await nextTick()
expect(screen.getByTestId('expanded-keys')).toHaveTextContent('folder-a')
// Second click collapses (covers both branches of handleNodeClick)
await user.click(screen.getByTestId('all-emit-folder'))
await nextTick()
expect(screen.getByTestId('expanded-keys')).toHaveTextContent('')
// No drag emitted for folder clicks
expect(mockStartDrag).not.toHaveBeenCalled()
})
})
describe('Search', () => {
it('should clear expanded keys when search produces no results', async () => {
const user = userEvent.setup()
mockSearchNode.mockReturnValue([])
renderComponent()
await user.click(screen.getByRole('tab', { name: /all/i }))
await nextTick()
// Pre-expand a folder so we can verify clearing actually happens
await user.click(screen.getByTestId('all-emit-folder'))
await nextTick()
expect(screen.getByTestId('expanded-keys')).toHaveTextContent('folder-a')
await user.type(screen.getByTestId('search-box'), 'nonexistent')
await user.click(screen.getByTestId('search-trigger'))
await nextTick()
expect(mockSearchNode).toHaveBeenCalled()
expect(screen.getByTestId('expanded-keys')).toHaveTextContent('')
})
it('should expand folder keys when search returns results', async () => {
const user = userEvent.setup()
const fakeDef = fromPartial<ComfyNodeDefImpl>({ name: 'Match' })
mockSearchNode.mockReturnValue([fakeDef])
mockOrganizeNodesByTab.mockReturnValue([
{
category: 'comfyNodes',
title: 'Comfy',
tree: {
key: 'root',
label: 'root',
children: [
{
key: 'root/folder1',
label: 'folder1',
leaf: false,
children: [
{ key: 'root/folder1/Match', label: 'Match', leaf: true }
]
}
]
}
}
])
renderComponent()
await user.click(screen.getByRole('tab', { name: /all/i }))
await nextTick()
await user.type(screen.getByTestId('search-box'), 'Match')
await user.click(screen.getByTestId('search-trigger'))
await nextTick()
expect(mockSearchNode).toHaveBeenCalledWith(
'Match',
[],
{ limit: 64 },
{ matchWildcards: false }
)
expect(screen.getByTestId('expanded-keys')).toHaveTextContent(
'root/folder1'
)
})
})
describe('Tab switching', () => {
it('should render the BlueprintsPanel when blueprints tab is selected', async () => {
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByRole('tab', { name: /blueprints/i }))
await nextTick()
expect(screen.getByTestId('blueprints-panel')).toBeInTheDocument()
expect(screen.queryByTestId('all-panel')).not.toBeInTheDocument()
})
it('should render the AllNodesPanel when all tab is selected', async () => {
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByRole('tab', { name: /all/i }))
await nextTick()
expect(screen.getByTestId('all-panel')).toBeInTheDocument()
expect(screen.queryByTestId('essential-panel')).not.toBeInTheDocument()
})
})
})

View File

@@ -1,158 +1,168 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.nodes')">
<template #header>
<SidebarTopArea bottom-divider>
<SearchInput
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@search="handleSearch"
/>
<template #actions>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
variant="secondary"
size="icon"
:aria-label="$t('g.sort')"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
class="z-9999 min-w-32 rounded-lg border border-border-default bg-comfy-menu-bg p-1 shadow-lg"
align="end"
:side-offset="4"
>
<DropdownMenuRadioGroup v-model="sortOrder">
<DropdownMenuRadioItem
v-for="option in sortingOptions"
:key="option.id"
:value="option.id"
<div class="h-full">
<SidebarTabTemplate v-if="!isHelpOpen" :title="$t('sideToolbar.nodes')">
<template #header>
<SidebarTopArea bottom-divider>
<SearchInput
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@search="handleSearch"
/>
<template #actions>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
variant="secondary"
size="icon"
:aria-label="$t('g.sort')"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
class="z-9999 min-w-32 rounded-lg border border-border-default bg-comfy-menu-bg p-1 shadow-lg"
align="end"
:side-offset="4"
>
<DropdownMenuRadioGroup v-model="sortOrder">
<DropdownMenuRadioItem
v-for="option in sortingOptions"
:key="option.id"
:value="option.id"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{ $t(option.label) }}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<DropdownMenuRoot v-if="selectedTab === 'all'">
<DropdownMenuTrigger as-child>
<Button
variant="secondary"
size="icon"
:aria-label="$t('sideToolbar.nodeLibraryTab.filter')"
>
<i class="icon-[lucide--list-filter] size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
class="z-9999 min-w-32 rounded-lg border border-border-default bg-comfy-menu-bg p-1 shadow-lg"
align="end"
:side-offset="4"
>
<DropdownMenuCheckboxItem
v-model="filterOptions.blueprints"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{ $t(option.label) }}</span>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<DropdownMenuRoot v-if="selectedTab === 'all'">
<DropdownMenuTrigger as-child>
<Button
variant="secondary"
size="icon"
:aria-label="$t('sideToolbar.nodeLibraryTab.filter')"
>
<i class="icon-[lucide--list-filter] size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
class="z-9999 min-w-32 rounded-lg border border-border-default bg-comfy-menu-bg p-1 shadow-lg"
align="end"
:side-offset="4"
>
<DropdownMenuCheckboxItem
v-model="filterOptions.blueprints"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.partnerNodes"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.comfyNodes"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.comfyNodes')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.extensions"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.extensions')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>
</SidebarTopArea>
<div class="border-b border-comfy-input p-2 2xl:px-4">
<TabList v-model="selectedTab">
<Tab v-for="tab in tabs" :key="tab.value" :value="tab.value">
{{ tab.label }}
</Tab>
</TabList>
</div>
</template>
<template #body>
<NodeDragPreview />
<div class="flex h-full flex-col">
<div class="min-h-0 flex-1 overflow-y-auto py-2">
<TabPanel
v-if="flags.nodeLibraryEssentialsEnabled"
:model-value="selectedTab"
value="essentials"
>
<EssentialNodesPanel
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
</TabPanel>
<TabPanel :model-value="selectedTab" value="all">
<AllNodesPanel
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
</TabPanel>
<TabPanel :model-value="selectedTab" value="blueprints">
<BlueprintsPanel
v-model:expanded-keys="expandedKeys"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabPanel>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.partnerNodes"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t(
'sideToolbar.nodeLibraryTab.filterOptions.partnerNodes'
)
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.comfyNodes"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.comfyNodes')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.extensions"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.extensions')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>
</SidebarTopArea>
<div class="border-b border-comfy-input p-2 2xl:px-4">
<TabList v-model="selectedTab">
<Tab v-for="tab in tabs" :key="tab.value" :value="tab.value">
{{ tab.label }}
</Tab>
</TabList>
</div>
</div>
</template>
</SidebarTabTemplate>
</template>
<template #body>
<NodeDragPreview />
<div class="flex h-full flex-col">
<div class="min-h-0 flex-1 overflow-y-auto py-2">
<TabPanel
v-if="flags.nodeLibraryEssentialsEnabled"
:model-value="selectedTab"
value="essentials"
>
<EssentialNodesPanel
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
</TabPanel>
<TabPanel :model-value="selectedTab" value="all">
<AllNodesPanel
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
</TabPanel>
<TabPanel :model-value="selectedTab" value="blueprints">
<BlueprintsPanel
v-model:expanded-keys="expandedKeys"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabPanel>
</div>
</div>
</template>
</SidebarTabTemplate>
<NodeHelpPage
v-else-if="currentHelpNode"
:node="currentHelpNode"
@close="closeHelp"
/>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useLocalStorage } from '@vueuse/core'
import {
DropdownMenuCheckboxItem,
@@ -189,6 +199,7 @@ import { getProviderIcon } from '@/utils/categoryUtil'
import { flattenTree, sortedTree, unwrapTreeRoot } from '@/utils/treeUtil'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { buildNodeDefTree, useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import type {
NodeCategoryId,
NodeSection,
@@ -205,10 +216,15 @@ import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
import NodeHelpPage from './nodeLibrary/NodeHelpPage.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
const { flags } = useFeatureFlags()
const nodeHelpStore = useNodeHelpStore()
const { currentHelpNode, isHelpOpen } = storeToRefs(nodeHelpStore)
const { closeHelp } = nodeHelpStore
const selectedTab = useLocalStorage<TabId>(
'Comfy.NodeLibrary.Tab',
DEFAULT_TAB_ID