From fd31f9d0eda240d429d1483f85498443e10f431c Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:36:40 -0700 Subject: [PATCH 01/39] additional node search updates - add root filter buttons - replace input/output selection with popover - replace price badge with one from node header - fix bug with hovering selecting item under mouse automatically - fix tailwind merge with custom sizes removing them - general tidy/refactor/test --- packages/tailwind-utils/src/index.ts | 10 +- src/components/node/CreditBadge.vue | 28 ++ src/components/node/NodePreviewCard.vue | 8 +- src/components/node/NodePricingBadge.vue | 13 +- .../v2/NodeSearchCategorySidebar.test.ts | 75 ++-- .../v2/NodeSearchCategorySidebar.vue | 117 ++++-- .../v2/NodeSearchCategoryTreeNode.vue | 74 ++-- .../searchbox/v2/NodeSearchContent.test.ts | 365 ++++++------------ .../searchbox/v2/NodeSearchContent.vue | 280 ++++++++------ .../searchbox/v2/NodeSearchFilterBar.test.ts | 73 ++-- .../searchbox/v2/NodeSearchFilterBar.vue | 153 +++++--- .../searchbox/v2/NodeSearchFilterPanel.vue | 90 ----- .../searchbox/v2/NodeSearchInput.test.ts | 70 +--- .../searchbox/v2/NodeSearchInput.vue | 87 ++--- .../searchbox/v2/NodeSearchListItem.vue | 90 +++-- .../v2/NodeSearchTypeFilterPopover.test.ts | 156 ++++++++ .../v2/NodeSearchTypeFilterPopover.vue | 165 ++++++++ .../searchbox/v2/__test__/testUtils.ts | 15 +- src/components/sidebar/ComfyMenuButton.vue | 2 +- src/locales/en/main.json | 2 + src/locales/en/settings.json | 3 +- .../vueNodes/components/NodeHeader.vue | 26 +- src/types/nodeSource.test.ts | 49 ++- src/types/nodeSource.ts | 14 + 24 files changed, 1139 insertions(+), 826 deletions(-) create mode 100644 src/components/node/CreditBadge.vue delete mode 100644 src/components/searchbox/v2/NodeSearchFilterPanel.vue create mode 100644 src/components/searchbox/v2/NodeSearchTypeFilterPopover.test.ts create mode 100644 src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue diff --git a/packages/tailwind-utils/src/index.ts b/packages/tailwind-utils/src/index.ts index 732aa4c60b3..13017144027 100644 --- a/packages/tailwind-utils/src/index.ts +++ b/packages/tailwind-utils/src/index.ts @@ -1,9 +1,17 @@ import { clsx } from 'clsx' import type { ClassArray } from 'clsx' -import { twMerge } from 'tailwind-merge' +import { extendTailwindMerge } from 'tailwind-merge' export type { ClassValue } from 'clsx' +const twMerge = extendTailwindMerge({ + extend: { + classGroups: { + 'font-size': ['text-xxs', 'text-xxxs'] + } + } +}) + export function cn(...inputs: ClassArray) { return twMerge(clsx(inputs)) } diff --git a/src/components/node/CreditBadge.vue b/src/components/node/CreditBadge.vue new file mode 100644 index 00000000000..a19bec25be4 --- /dev/null +++ b/src/components/node/CreditBadge.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/components/node/NodePreviewCard.vue b/src/components/node/NodePreviewCard.vue index 557369a66f0..4e950190d54 100644 --- a/src/components/node/NodePreviewCard.vue +++ b/src/components/node/NodePreviewCard.vue @@ -18,14 +18,14 @@

- {{ nodeDef.category.replaceAll('/', ' > ') }} + {{ nodeDef.category.replaceAll('/', ' / ') }}

-
- +
+
diff --git a/src/components/node/NodePricingBadge.vue b/src/components/node/NodePricingBadge.vue index 74f63499d33..3b7556c265c 100644 --- a/src/components/node/NodePricingBadge.vue +++ b/src/components/node/NodePricingBadge.vue @@ -1,18 +1,13 @@ diff --git a/src/components/searchbox/v2/NodeSearchFilterPanel.vue b/src/components/searchbox/v2/NodeSearchFilterPanel.vue deleted file mode 100644 index d9d9077f226..00000000000 --- a/src/components/searchbox/v2/NodeSearchFilterPanel.vue +++ /dev/null @@ -1,90 +0,0 @@ - - - diff --git a/src/components/searchbox/v2/NodeSearchInput.test.ts b/src/components/searchbox/v2/NodeSearchInput.test.ts index 5bc875466af..17dadabb0c7 100644 --- a/src/components/searchbox/v2/NodeSearchInput.test.ts +++ b/src/components/searchbox/v2/NodeSearchInput.test.ts @@ -1,7 +1,6 @@ import { mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue' import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue' import { setupTestPinia, @@ -18,7 +17,11 @@ vi.mock('@/utils/litegraphUtil', () => ({ vi.mock('@/platform/settings/settingStore', () => ({ useSettingStore: vi.fn(() => ({ - get: vi.fn(), + get: vi.fn((key: string) => { + if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return [] + if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {} + return undefined + }), set: vi.fn() })) })) @@ -39,20 +42,6 @@ function createFilter( } } -function createActiveFilter(label: string): FilterChip { - return { - key: label.toLowerCase(), - label, - filter: { - id: label.toLowerCase(), - matches: vi.fn(() => true) - } as Partial> as FuseFilter< - ComfyNodeDefImpl, - string - > - } -} - describe('NodeSearchInput', () => { beforeEach(() => { setupTestPinia() @@ -62,51 +51,27 @@ describe('NodeSearchInput', () => { function createWrapper( props: Partial<{ filters: FuseFilterWithValue[] - activeFilter: FilterChip | null searchQuery: string - filterQuery: string }> = {} ) { return mount(NodeSearchInput, { props: { filters: [], - activeFilter: null, searchQuery: '', - filterQuery: '', ...props }, global: { plugins: [testI18n] } }) } - it('should route input to searchQuery when no active filter', async () => { + it('should route input to searchQuery', async () => { const wrapper = createWrapper() await wrapper.find('input').setValue('test search') expect(wrapper.emitted('update:searchQuery')![0]).toEqual(['test search']) }) - it('should route input to filterQuery when active filter is set', async () => { - const wrapper = createWrapper({ - activeFilter: createActiveFilter('Input') - }) - await wrapper.find('input').setValue('IMAGE') - - expect(wrapper.emitted('update:filterQuery')![0]).toEqual(['IMAGE']) - expect(wrapper.emitted('update:searchQuery')).toBeUndefined() - }) - - it('should show filter label placeholder when active filter is set', () => { - const wrapper = createWrapper({ - activeFilter: createActiveFilter('Input') - }) - - expect( - (wrapper.find('input').element as HTMLInputElement).placeholder - ).toContain('input') - }) - - it('should show add node placeholder when no active filter', () => { + it('should show add node placeholder', () => { const wrapper = createWrapper() expect( @@ -114,16 +79,7 @@ describe('NodeSearchInput', () => { ).toContain('Add a node') }) - it('should hide filter chips when active filter is set', () => { - const wrapper = createWrapper({ - filters: [createFilter('input', 'IMAGE')], - activeFilter: createActiveFilter('Input') - }) - - expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(0) - }) - - it('should show filter chips when no active filter', () => { + it('should show filter chips when filters are present', () => { const wrapper = createWrapper({ filters: [createFilter('input', 'IMAGE')] }) @@ -131,16 +87,6 @@ describe('NodeSearchInput', () => { expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(1) }) - it('should emit cancelFilter when cancel button is clicked', async () => { - const wrapper = createWrapper({ - activeFilter: createActiveFilter('Input') - }) - - await wrapper.find('[data-testid="cancel-filter"]').trigger('click') - - expect(wrapper.emitted('cancelFilter')).toHaveLength(1) - }) - it('should emit selectCurrent on Enter', async () => { const wrapper = createWrapper() diff --git a/src/components/searchbox/v2/NodeSearchInput.vue b/src/components/searchbox/v2/NodeSearchInput.vue index e6ffc026121..0fdf290565b 100644 --- a/src/components/searchbox/v2/NodeSearchInput.vue +++ b/src/components/searchbox/v2/NodeSearchInput.vue @@ -7,60 +7,40 @@ @remove-tag="onRemoveTag" @click="inputRef?.focus()" > - - + - {{ activeFilter.label }}: - - - - + + [] - activeFilter: FilterChip | null }>() const searchQuery = defineModel('searchQuery', { required: true }) -const filterQuery = defineModel('filterQuery', { required: true }) const emit = defineEmits<{ removeFilter: [filter: FuseFilterWithValue] - cancelFilter: [] navigateDown: [] navigateUp: [] selectCurrent: [] @@ -105,23 +81,6 @@ const emit = defineEmits<{ const { t } = useI18n() const inputRef = ref() -const inputValue = computed({ - get: () => (activeFilter ? filterQuery.value : searchQuery.value), - set: (value: string) => { - if (activeFilter) { - filterQuery.value = value - } else { - searchQuery.value = value - } - } -}) - -const inputPlaceholder = computed(() => - activeFilter - ? t('g.filterByType', { type: activeFilter.label.toLowerCase() }) - : t('g.addNode') -) - const tagValues = computed(() => filters.map(filterKey)) function filterKey(filter: FuseFilterWithValue) { diff --git a/src/components/searchbox/v2/NodeSearchListItem.vue b/src/components/searchbox/v2/NodeSearchListItem.vue index 7073fb400ef..42552f1c189 100644 --- a/src/components/searchbox/v2/NodeSearchListItem.vue +++ b/src/components/searchbox/v2/NodeSearchListItem.vue @@ -2,46 +2,77 @@
-
+
+
- -   + - - + +
+
- - {{ nodeDef.nodeSource.displayText }} + + {{ nodeDef.category.replaceAll('/', ' / ') }} - + + {{ nodeDef.description }}
-
- {{ nodeDef.category.replaceAll('/', ' > ') }} -
() +const badgePillClass = + 'flex h-[18px] max-w-28 shrink-0 items-center justify-center gap-1 rounded-full bg-secondary-background-hover/80 px-2' + const settingStore = useSettingStore() const showCategory = computed(() => settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory') @@ -122,4 +162,6 @@ const nodeFrequency = computed(() => const nodeBookmarkStore = useNodeBookmarkStore() const isBookmarked = computed(() => nodeBookmarkStore.isBookmarked(nodeDef)) +const providerName = computed(() => getProviderName(nodeDef.category)) +const isCustom = computed(() => isCustomNodeDef(nodeDef)) diff --git a/src/components/searchbox/v2/NodeSearchTypeFilterPopover.test.ts b/src/components/searchbox/v2/NodeSearchTypeFilterPopover.test.ts new file mode 100644 index 00000000000..14035a33030 --- /dev/null +++ b/src/components/searchbox/v2/NodeSearchTypeFilterPopover.test.ts @@ -0,0 +1,156 @@ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue' +import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue' +import { testI18n } from '@/components/searchbox/v2/__test__/testUtils' + +function createMockChip( + data: string[] = ['IMAGE', 'LATENT', 'MODEL'] +): FilterChip { + return { + key: 'input', + label: 'Input', + filter: { + id: 'input', + matches: vi.fn(), + fuseSearch: { + search: vi.fn((query: string) => + data.filter((d) => d.toLowerCase().includes(query.toLowerCase())) + ), + data + } + } as unknown as FilterChip['filter'] + } +} + +describe(NodeSearchTypeFilterPopover, () => { + let wrapper: ReturnType + + beforeEach(() => { + vi.restoreAllMocks() + }) + + afterEach(() => { + wrapper?.unmount() + }) + + function createWrapper( + props: { + chip?: FilterChip + selectedValues?: string[] + } = {} + ) { + wrapper = mount(NodeSearchTypeFilterPopover, { + props: { + chip: props.chip ?? createMockChip(), + selectedValues: props.selectedValues ?? [] + }, + slots: { + default: '' + }, + global: { + plugins: [testI18n] + }, + attachTo: document.body + }) + return wrapper + } + + async function openPopover(w: ReturnType) { + await w.find('[data-testid="trigger"]').trigger('click') + await nextTick() + await nextTick() + } + + function getOptions() { + return wrapper.findAll('[role="option"]') + } + + it('should render the trigger slot', () => { + createWrapper() + expect(wrapper.find('[data-testid="trigger"]').exists()).toBe(true) + }) + + it('should show popover content when trigger is clicked', async () => { + createWrapper() + await openPopover(wrapper) + expect(wrapper.find('[role="listbox"]').exists()).toBe(true) + }) + + it('should display all options sorted alphabetically', async () => { + createWrapper() + await openPopover(wrapper) + + const options = getOptions() + expect(options).toHaveLength(3) + const texts = options.map((o) => o.text().trim()) + expect(texts[0]).toContain('IMAGE') + expect(texts[1]).toContain('LATENT') + expect(texts[2]).toContain('MODEL') + }) + + it('should show selected count text', async () => { + createWrapper({ selectedValues: ['IMAGE', 'LATENT'] }) + await openPopover(wrapper) + + expect(wrapper.text()).toContain('2 items selected') + }) + + it('should show clear all button only when values are selected', async () => { + createWrapper({ selectedValues: [] }) + await openPopover(wrapper) + + const buttons = wrapper.findAll('button') + const clearBtn = buttons.find((b) => b.text().includes('Clear all')) + expect(clearBtn).toBeUndefined() + }) + + it('should show clear all button when values are selected', async () => { + createWrapper({ selectedValues: ['IMAGE'] }) + await openPopover(wrapper) + + const buttons = wrapper.findAll('button') + const clearBtn = buttons.find((b) => b.text().includes('Clear all')) + expect(clearBtn).toBeTruthy() + }) + + it('should emit clear when clear all button is clicked', async () => { + createWrapper({ selectedValues: ['IMAGE'] }) + await openPopover(wrapper) + + const clearBtn = wrapper + .findAll('button') + .find((b) => b.text().includes('Clear all'))! + await clearBtn.trigger('click') + await nextTick() + + expect(wrapper.emitted('clear')).toHaveLength(1) + }) + + it('should filter options via search input', async () => { + createWrapper() + await openPopover(wrapper) + + const searchInput = wrapper.find('input') + await searchInput.setValue('IMAGE') + await nextTick() + + const options = getOptions() + expect(options).toHaveLength(1) + expect(options[0].text()).toContain('IMAGE') + }) + + it('should show no results when search matches nothing', async () => { + createWrapper() + await openPopover(wrapper) + + const searchInput = wrapper.find('input') + await searchInput.setValue('NONEXISTENT') + await nextTick() + + expect(getOptions()).toHaveLength(0) + expect(wrapper.text()).toContain('No results') + }) +}) diff --git a/src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue b/src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue new file mode 100644 index 00000000000..dd16acc7e3b --- /dev/null +++ b/src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue @@ -0,0 +1,165 @@ + + + diff --git a/src/components/searchbox/v2/__test__/testUtils.ts b/src/components/searchbox/v2/__test__/testUtils.ts index 5e1dc700c7c..9eb8523c770 100644 --- a/src/components/searchbox/v2/__test__/testUtils.ts +++ b/src/components/searchbox/v2/__test__/testUtils.ts @@ -49,15 +49,12 @@ export const testI18n = createI18n({ input: 'Input', output: 'Output', source: 'Source', - search: 'Search' - }, - sideToolbar: { - nodeLibraryTab: { - filterOptions: { - blueprints: 'Blueprints', - partnerNodes: 'Partner Nodes' - } - } + search: 'Search', + blueprints: 'Blueprints', + partnerNodes: 'Partner Nodes', + remove: 'Remove', + itemsSelected: '{selectedCount} items selected', + clearAll: 'Clear all' } } } diff --git a/src/components/sidebar/ComfyMenuButton.vue b/src/components/sidebar/ComfyMenuButton.vue index 4527b7aa1ef..7e473c63f2f 100644 --- a/src/components/sidebar/ComfyMenuButton.vue +++ b/src/components/sidebar/ComfyMenuButton.vue @@ -14,7 +14,7 @@
- + { it('should return UNKNOWN_NODE_SOURCE when python_module is undefined', () => { @@ -108,3 +114,44 @@ describe('getNodeSource', () => { }) }) }) + +function makeNode(type: NodeSourceType): { nodeSource: NodeSource } { + return { + nodeSource: { + type, + className: '', + displayText: '', + badgeText: '' + } + } +} + +describe('isEssentialNode', () => { + it('returns true for Essentials nodes', () => { + expect(isEssentialNode(makeNode(NodeSourceType.Essentials))).toBe(true) + }) + + it.for([ + NodeSourceType.Core, + NodeSourceType.CustomNodes, + NodeSourceType.Blueprint, + NodeSourceType.Unknown + ])('returns false for %s nodes', (type) => { + expect(isEssentialNode(makeNode(type))).toBe(false) + }) +}) + +describe('isCustomNode', () => { + it('returns true for CustomNodes', () => { + expect(isCustomNode(makeNode(NodeSourceType.CustomNodes))).toBe(true) + }) + + it.for([ + NodeSourceType.Core, + NodeSourceType.Essentials, + NodeSourceType.Unknown, + NodeSourceType.Blueprint + ])('returns false for %s nodes', (type) => { + expect(isCustomNode(makeNode(type))).toBe(false) + }) +}) diff --git a/src/types/nodeSource.ts b/src/types/nodeSource.ts index a6931509e34..b2089d7625e 100644 --- a/src/types/nodeSource.ts +++ b/src/types/nodeSource.ts @@ -1,3 +1,5 @@ +export const BLUEPRINT_CATEGORY = 'Subgraph Blueprints' + export enum NodeSourceType { Core = 'core', CustomNodes = 'custom_nodes', @@ -76,6 +78,18 @@ export function getNodeSource( } } +interface NodeDefLike { + nodeSource: NodeSource +} + +export function isEssentialNode(node: NodeDefLike): boolean { + return node.nodeSource.type === NodeSourceType.Essentials +} + +export function isCustomNode(node: NodeDefLike): boolean { + return node.nodeSource.type === NodeSourceType.CustomNodes +} + export enum NodeBadgeMode { None = 'None', ShowAll = 'Show all', From 0f3b2e0455f6d6267f4efbca4a2e518bbff6f6bf Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:19:42 -0700 Subject: [PATCH 02/39] fix test --- browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts | 4 +++- browser_tests/tests/nodeSearchBoxV2.spec.ts | 2 +- src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts b/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts index 64f6b9cebbd..9b914548086 100644 --- a/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts +++ b/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts @@ -5,12 +5,14 @@ import type { ComfyPage } from '../ComfyPage' export class ComfyNodeSearchBoxV2 { readonly dialog: Locator readonly input: Locator + readonly filterSearch: Locator readonly results: Locator readonly filterOptions: Locator constructor(readonly page: Page) { this.dialog = page.getByRole('search') - this.input = this.dialog.locator('input[type="text"]') + this.input = this.dialog.getByRole('combobox') + this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' }) this.results = this.dialog.getByTestId('result-item') this.filterOptions = this.dialog.getByTestId('filter-option') } diff --git a/browser_tests/tests/nodeSearchBoxV2.spec.ts b/browser_tests/tests/nodeSearchBoxV2.spec.ts index 0c91963eedb..649c7a21966 100644 --- a/browser_tests/tests/nodeSearchBoxV2.spec.ts +++ b/browser_tests/tests/nodeSearchBoxV2.spec.ts @@ -100,7 +100,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => { await expect(searchBoxV2.filterOptions.first()).toBeVisible() // Type to narrow and select MODEL - await searchBoxV2.input.fill('MODEL') + await searchBoxV2.filterSearch.fill('MODEL') await searchBoxV2.filterOptions .filter({ hasText: 'MODEL' }) .first() diff --git a/src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue b/src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue index dd16acc7e3b..5bb0a8d56cf 100644 --- a/src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue +++ b/src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue @@ -54,6 +54,7 @@ v-for="option in filteredOptions" :key="option" :value="option" + data-testid="filter-option" class="text-foreground flex cursor-pointer items-center gap-2 rounded-sm px-1 py-2 text-sm outline-none data-highlighted:bg-secondary-background-hover" > Date: Thu, 12 Mar 2026 04:18:20 -0700 Subject: [PATCH 03/39] additional search feedback - improved keyboard navigation and aria - fixed alignment of elements - updated fonts and sizes - more tidy + nits - tests --- src/components/node/NodePreviewCard.vue | 18 ++- .../searchbox/NodeSearchBoxPopover.vue | 4 +- .../v2/NodeSearchCategorySidebar.test.ts | 145 +++++++++++++++++- .../v2/NodeSearchCategorySidebar.vue | 66 +++++--- .../v2/NodeSearchCategoryTreeNode.vue | 100 ++++++++---- .../searchbox/v2/NodeSearchContent.test.ts | 44 ++++++ .../searchbox/v2/NodeSearchContent.vue | 73 +++++---- .../searchbox/v2/NodeSearchFilterBar.vue | 4 +- .../searchbox/v2/NodeSearchInput.vue | 6 +- .../searchbox/v2/NodeSearchListItem.vue | 6 +- .../v2/NodeSearchTypeFilterPopover.test.ts | 12 ++ .../v2/NodeSearchTypeFilterPopover.vue | 17 +- .../searchbox/v2/__test__/testUtils.ts | 1 + 13 files changed, 380 insertions(+), 116 deletions(-) diff --git a/src/components/node/NodePreviewCard.vue b/src/components/node/NodePreviewCard.vue index 4e950190d54..fef289290d6 100644 --- a/src/components/node/NodePreviewCard.vue +++ b/src/components/node/NodePreviewCard.vue @@ -1,9 +1,14 @@