mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-21 04:47:34 +00:00
Compare commits
10 Commits
fix/load-a
...
pysssss/no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06f060dda9 | ||
|
|
878f7cb8b2 | ||
|
|
d1ccd10896 | ||
|
|
3cf61f9c39 | ||
|
|
5563916546 | ||
|
|
7bc57bc84c | ||
|
|
edd99ead6d | ||
|
|
9435c1b2c3 | ||
|
|
d36e52d36f | ||
|
|
ccced06925 |
@@ -6,13 +6,27 @@ export class ComfyNodeSearchBoxV2 {
|
||||
readonly dialog: Locator
|
||||
readonly input: 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.results = this.dialog.getByTestId('result-item')
|
||||
this.filterOptions = this.dialog.getByTestId('filter-option')
|
||||
}
|
||||
|
||||
get filterChips(): Locator {
|
||||
return this.dialog.getByTestId('filter-chip')
|
||||
}
|
||||
|
||||
get filterPopover(): Locator {
|
||||
return this.dialog.getByRole('dialog')
|
||||
}
|
||||
|
||||
get filterPopoverOptions(): Locator {
|
||||
return this.filterPopover.getByRole('option')
|
||||
}
|
||||
|
||||
get filterPopoverSearch(): Locator {
|
||||
return this.filterPopover.getByRole('textbox', { name: 'Search' })
|
||||
}
|
||||
|
||||
categoryButton(categoryId: string): Locator {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 98 KiB |
@@ -93,23 +93,23 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
// Click "Input" filter chip in the filter bar
|
||||
// Click "Input" filter chip to open the popover
|
||||
await searchBoxV2.filterBarButton('Input').click()
|
||||
|
||||
// Filter options should appear
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
await expect(searchBoxV2.filterPopover).toBeVisible()
|
||||
|
||||
// Type to narrow and select MODEL
|
||||
await searchBoxV2.input.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
await searchBoxV2.filterPopoverSearch.fill('MODEL')
|
||||
await searchBoxV2.filterPopoverOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
// Close the popover by pressing Escape
|
||||
await searchBoxV2.filterPopoverSearch.press('Escape')
|
||||
await expect(searchBoxV2.filterPopover).not.toBeVisible()
|
||||
|
||||
// Filter chip should appear and results should be filtered
|
||||
await expect(
|
||||
searchBoxV2.dialog.getByText('Input:', { exact: false }).locator('..')
|
||||
).toContainText('MODEL')
|
||||
await expect(searchBoxV2.filterChips.first()).toContainText('MODEL')
|
||||
await expect(searchBoxV2.results.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
28
src/components/node/CreditBadge.vue
Normal file
28
src/components/node/CreditBadge.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex h-5 items-center bg-component-node-widget-background p-1 text-xs shrink-0',
|
||||
rest ? 'rounded-l-full pr-1' : 'rounded-full'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="h-full icon-[lucide--component] bg-amber-400" />
|
||||
<span class="truncate" v-text="text" />
|
||||
</span>
|
||||
<span
|
||||
v-if="rest"
|
||||
class="-ml-2.5 min-w-0 max-w-max grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
|
||||
>
|
||||
<span class="pr-2" v-text="rest" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
text: string
|
||||
rest?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -24,8 +24,8 @@
|
||||
</p>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex flex-wrap gap-2 empty:hidden">
|
||||
<NodePricingBadge :node-def="nodeDef" />
|
||||
<div class="flex flex-wrap gap-2 empty:hidden overflow-hidden">
|
||||
<NodePricingBadge class="max-w-full truncate" :node-def="nodeDef" />
|
||||
<NodeProviderBadge :node-def="nodeDef" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
<template>
|
||||
<BadgePill
|
||||
v-if="nodeDef.api_node"
|
||||
v-show="priceLabel"
|
||||
:text="priceLabel"
|
||||
icon="icon-[comfy--credits]"
|
||||
border-style="#f59e0b"
|
||||
filled
|
||||
/>
|
||||
<span v-if="nodeDef.api_node && priceLabel">
|
||||
<CreditBadge :text="priceLabel" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import BadgePill from '@/components/common/BadgePill.vue'
|
||||
import CreditBadge from '@/components/node/CreditBadge.vue'
|
||||
import { evaluateNodeDefPricing } from '@/composables/node/useNodePricing'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
setupTestPinia,
|
||||
testI18n
|
||||
} from '@/components/searchbox/v2/__test__/testUtils'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -21,6 +22,7 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setupTestPinia()
|
||||
vi.spyOn(useNodeBookmarkStore(), 'bookmarks', 'get').mockReturnValue([])
|
||||
})
|
||||
|
||||
async function createWrapper(props = {}) {
|
||||
@@ -47,6 +49,9 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
|
||||
describe('preset categories', () => {
|
||||
it('should render all preset categories', async () => {
|
||||
vi.spyOn(useNodeBookmarkStore(), 'bookmarks', 'get').mockReturnValue([
|
||||
'placeholder'
|
||||
])
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'EssentialNode',
|
||||
@@ -74,6 +79,9 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
})
|
||||
|
||||
it('should emit update:selectedCategory when preset is clicked', async () => {
|
||||
vi.spyOn(useNodeBookmarkStore(), 'bookmarks', 'get').mockReturnValue([
|
||||
'placeholder'
|
||||
])
|
||||
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
|
||||
|
||||
await clickCategory(wrapper, 'Favorites')
|
||||
@@ -245,6 +253,88 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
expect(emitted[emitted.length - 1]).toEqual(['api/image/BFL'])
|
||||
})
|
||||
|
||||
describe('rootLabel wrapping', () => {
|
||||
it('should wrap multiple roots under a parent when rootLabel is provided', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'loaders' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const wrapper = await createWrapper({
|
||||
hidePresets: true,
|
||||
rootLabel: 'Extensions',
|
||||
rootKey: 'custom'
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Extensions')
|
||||
})
|
||||
|
||||
it('should auto-expand the synthetic root and keep it expanded when selecting a child', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'loaders' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const wrapper = await createWrapper({
|
||||
hidePresets: true,
|
||||
rootLabel: 'Extensions',
|
||||
rootKey: 'custom'
|
||||
})
|
||||
|
||||
// Auto-expanded: children should be visible
|
||||
expect(wrapper.text()).toContain('sampling')
|
||||
expect(wrapper.text()).toContain('loaders')
|
||||
|
||||
// Select a child category
|
||||
await clickCategory(wrapper, 'sampling')
|
||||
await nextTick()
|
||||
|
||||
// Parent should stay expanded (children still visible)
|
||||
expect(wrapper.text()).toContain('Extensions')
|
||||
expect(wrapper.text()).toContain('sampling')
|
||||
expect(wrapper.text()).toContain('loaders')
|
||||
})
|
||||
|
||||
it('should prefix child keys with rootKey', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'loaders' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const wrapper = await createWrapper({
|
||||
hidePresets: true,
|
||||
rootLabel: 'Extensions',
|
||||
rootKey: 'custom'
|
||||
})
|
||||
|
||||
await clickCategory(wrapper, 'sampling')
|
||||
|
||||
const emitted = wrapper.emitted('update:selectedCategory')!
|
||||
expect(emitted[emitted.length - 1]).toEqual(['custom/sampling'])
|
||||
})
|
||||
|
||||
it('should not wrap when there is only one root category', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const wrapper = await createWrapper({
|
||||
hidePresets: true,
|
||||
rootLabel: 'Extensions',
|
||||
rootKey: 'custom'
|
||||
})
|
||||
|
||||
// No wrapping — "Extensions" parent should not appear
|
||||
expect(wrapper.text()).not.toContain('Extensions')
|
||||
expect(wrapper.text()).toContain('sampling')
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit category without root/ prefix', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' })
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col overflow-y-auto py-2.5">
|
||||
<div
|
||||
class="flex min-h-0 flex-col overflow-y-auto py-2.5 [&:hover_.tree-chevron]:opacity-100"
|
||||
>
|
||||
<!-- Preset categories -->
|
||||
<div class="flex flex-col px-1">
|
||||
<div v-if="!hidePresets" class="flex flex-col px-1">
|
||||
<button
|
||||
v-for="preset in topCategories"
|
||||
:key="preset.id"
|
||||
@@ -16,7 +18,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Source categories -->
|
||||
<div class="my-2 flex flex-col border-y border-border-subtle px-1 py-2">
|
||||
<div
|
||||
v-if="!hidePresets && sourceCategories.length > 0"
|
||||
class="my-2 flex flex-col border-y border-border-subtle px-1 py-2"
|
||||
>
|
||||
<button
|
||||
v-for="preset in sourceCategories"
|
||||
:key="preset.id"
|
||||
@@ -31,13 +36,23 @@
|
||||
</div>
|
||||
|
||||
<!-- Category tree -->
|
||||
<div class="flex flex-col px-1">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col px-1',
|
||||
!hidePresets &&
|
||||
!sourceCategories.length &&
|
||||
'mt-2 border-t border-border-subtle pt-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<NodeSearchCategoryTreeNode
|
||||
v-for="category in categoryTree"
|
||||
:key="category.key"
|
||||
:node="category"
|
||||
:selected-category="selectedCategory"
|
||||
:selected-collapsed="selectedCollapsed"
|
||||
:hide-chevrons="hideChevrons"
|
||||
@select="selectCategory"
|
||||
/>
|
||||
</div>
|
||||
@@ -45,7 +60,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NodeSearchCategoryTreeNode, {
|
||||
@@ -54,12 +69,28 @@ import NodeSearchCategoryTreeNode, {
|
||||
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
|
||||
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
hideChevrons = false,
|
||||
hidePresets = false,
|
||||
nodeDefs,
|
||||
rootLabel,
|
||||
rootKey
|
||||
} = defineProps<{
|
||||
hideChevrons?: boolean
|
||||
hidePresets?: boolean
|
||||
nodeDefs?: ComfyNodeDefImpl[]
|
||||
rootLabel?: string
|
||||
rootKey?: string
|
||||
}>()
|
||||
|
||||
const selectedCategory = defineModel<string>('selectedCategory', {
|
||||
required: true
|
||||
})
|
||||
@@ -67,21 +98,27 @@ const selectedCategory = defineModel<string>('selectedCategory', {
|
||||
const { t } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
const topCategories = computed(() => [
|
||||
{ id: 'most-relevant', label: t('g.mostRelevant') },
|
||||
{ id: 'favorites', label: t('g.favorites') }
|
||||
])
|
||||
const topCategories = computed(() => {
|
||||
const categories = [{ id: 'most-relevant', label: t('g.mostRelevant') }]
|
||||
if (nodeBookmarkStore.bookmarks.length > 0) {
|
||||
categories.push({ id: 'favorites', label: t('g.favorites') })
|
||||
}
|
||||
return categories
|
||||
})
|
||||
|
||||
const hasEssentialNodes = computed(() =>
|
||||
nodeDefStore.visibleNodeDefs.some(
|
||||
(n) => n.nodeSource.type === NodeSourceType.Essentials
|
||||
)
|
||||
const hasEssentialNodes = computed(
|
||||
() =>
|
||||
flags.nodeLibraryEssentialsEnabled &&
|
||||
nodeDefStore.visibleNodeDefs.some(
|
||||
(n) => n.nodeSource.type === NodeSourceType.Essentials
|
||||
)
|
||||
)
|
||||
|
||||
const sourceCategories = computed(() => {
|
||||
const categories = []
|
||||
if (flags.nodeLibraryEssentialsEnabled && hasEssentialNodes.value) {
|
||||
if (hasEssentialNodes.value) {
|
||||
categories.push({ id: 'essentials', label: t('g.essentials') })
|
||||
}
|
||||
categories.push({ id: 'custom', label: t('g.custom') })
|
||||
@@ -89,10 +126,10 @@ const sourceCategories = computed(() => {
|
||||
})
|
||||
|
||||
const categoryTree = computed<CategoryNode[]>(() => {
|
||||
const tree = nodeOrganizationService.organizeNodes(
|
||||
nodeDefStore.visibleNodeDefs,
|
||||
{ groupBy: 'category' }
|
||||
)
|
||||
const defs = nodeDefs ?? nodeDefStore.visibleNodeDefs
|
||||
const tree = nodeOrganizationService.organizeNodes(defs, {
|
||||
groupBy: 'category'
|
||||
})
|
||||
|
||||
const stripRootPrefix = (key: string) => key.replace(/^root\//, '')
|
||||
|
||||
@@ -107,22 +144,50 @@ const categoryTree = computed<CategoryNode[]>(() => {
|
||||
}
|
||||
}
|
||||
|
||||
return (tree.children ?? [])
|
||||
const nodes = (tree.children ?? [])
|
||||
.filter((node): node is TreeNode => !node.leaf)
|
||||
.map(mapNode)
|
||||
|
||||
if (rootLabel && nodes.length > 1) {
|
||||
const key = rootKey ?? rootLabel.toLowerCase()
|
||||
function prefixKeys(node: CategoryNode): CategoryNode {
|
||||
return {
|
||||
key: key + '/' + node.key,
|
||||
label: node.label,
|
||||
...(node.children?.length
|
||||
? { children: node.children.map(prefixKeys) }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
return [{ key, label: rootLabel, children: nodes.map(prefixKeys) }]
|
||||
}
|
||||
|
||||
return nodes
|
||||
})
|
||||
|
||||
const selectedCollapsed = ref(false)
|
||||
|
||||
watch(
|
||||
categoryTree,
|
||||
(nodes) => {
|
||||
if (rootLabel && nodes.length === 1) {
|
||||
selectedCategory.value = nodes[0].key
|
||||
selectedCollapsed.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function categoryBtnClass(id: string) {
|
||||
return cn(
|
||||
'cursor-pointer border-none bg-transparent rounded px-3 py-2.5 text-left text-sm transition-colors',
|
||||
'cursor-pointer border-none bg-transparent rounded py-2.5 pr-3 text-left text-sm transition-colors',
|
||||
hideChevrons ? 'pl-3' : 'pl-9',
|
||||
selectedCategory.value === id
|
||||
? CATEGORY_SELECTED_CLASS
|
||||
: CATEGORY_UNSELECTED_CLASS
|
||||
)
|
||||
}
|
||||
|
||||
const selectedCollapsed = ref(false)
|
||||
|
||||
function selectCategory(categoryId: string) {
|
||||
if (selectedCategory.value === categoryId) {
|
||||
selectedCollapsed.value = !selectedCollapsed.value
|
||||
|
||||
@@ -1,32 +1,54 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:data-testid="`category-${node.key}`"
|
||||
:aria-current="selectedCategory === node.key || undefined"
|
||||
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'w-full cursor-pointer rounded border-none bg-transparent py-2.5 pr-3 text-left text-sm transition-colors',
|
||||
selectedCategory === node.key
|
||||
? CATEGORY_SELECTED_CLASS
|
||||
: CATEGORY_UNSELECTED_CLASS
|
||||
selectedCategory === node.key &&
|
||||
isExpanded &&
|
||||
node.children?.length &&
|
||||
'rounded bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="$emit('select', node.key)"
|
||||
>
|
||||
{{ node.label }}
|
||||
</button>
|
||||
<template v-if="isExpanded && node.children?.length">
|
||||
<NodeSearchCategoryTreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.key"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
:selected-category="selectedCategory"
|
||||
:selected-collapsed="selectedCollapsed"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
</template>
|
||||
<button
|
||||
type="button"
|
||||
:data-testid="`category-${node.key}`"
|
||||
:aria-current="selectedCategory === node.key || undefined"
|
||||
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent py-2.5 pr-3 text-left text-sm transition-colors',
|
||||
selectedCategory === node.key
|
||||
? CATEGORY_SELECTED_CLASS
|
||||
: CATEGORY_UNSELECTED_CLASS
|
||||
)
|
||||
"
|
||||
@click="$emit('select', node.key)"
|
||||
>
|
||||
<i
|
||||
v-if="!hideChevrons"
|
||||
:class="
|
||||
cn(
|
||||
'tree-chevron icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground opacity-0 transition-[transform,opacity] duration-150',
|
||||
!node.children?.length && 'invisible',
|
||||
node.children?.length && !isExpanded && '-rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ node.label }}</span>
|
||||
</button>
|
||||
<template v-if="isExpanded && node.children?.length">
|
||||
<NodeSearchCategoryTreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.key"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
:selected-category="selectedCategory"
|
||||
:selected-collapsed="selectedCollapsed"
|
||||
:hide-chevrons="hideChevrons"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -51,12 +73,14 @@ const {
|
||||
node,
|
||||
depth = 0,
|
||||
selectedCategory,
|
||||
selectedCollapsed = false
|
||||
selectedCollapsed = false,
|
||||
hideChevrons = false
|
||||
} = defineProps<{
|
||||
node: CategoryNode
|
||||
depth?: number
|
||||
selectedCategory: string
|
||||
selectedCollapsed?: boolean
|
||||
hideChevrons?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -3,7 +3,6 @@ import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
|
||||
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
|
||||
import {
|
||||
createMockNodeDef,
|
||||
@@ -59,7 +58,9 @@ describe('NodeSearchContent', () => {
|
||||
nodes: Parameters<typeof createMockNodeDef>[0][]
|
||||
) {
|
||||
useNodeDefStore().updateNodeDefs(nodes.map(createMockNodeDef))
|
||||
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(true)
|
||||
const bookmarkStore = useNodeBookmarkStore()
|
||||
vi.spyOn(bookmarkStore, 'isBookmarked').mockReturnValue(true)
|
||||
vi.spyOn(bookmarkStore, 'bookmarks', 'get').mockReturnValue(['placeholder'])
|
||||
const wrapper = await createWrapper()
|
||||
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
|
||||
await nextTick()
|
||||
@@ -106,9 +107,13 @@ describe('NodeSearchContent', () => {
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockImplementation(
|
||||
const bookmarkStore = useNodeBookmarkStore()
|
||||
vi.spyOn(bookmarkStore, 'isBookmarked').mockImplementation(
|
||||
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode'
|
||||
)
|
||||
vi.spyOn(bookmarkStore, 'bookmarks', 'get').mockReturnValue([
|
||||
'BookmarkedNode'
|
||||
])
|
||||
|
||||
const wrapper = await createWrapper()
|
||||
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
|
||||
@@ -123,7 +128,11 @@ describe('NodeSearchContent', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', display_name: 'Node One' })
|
||||
])
|
||||
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
|
||||
const bookmarkStore = useNodeBookmarkStore()
|
||||
vi.spyOn(bookmarkStore, 'isBookmarked').mockReturnValue(false)
|
||||
vi.spyOn(bookmarkStore, 'bookmarks', 'get').mockReturnValue([
|
||||
'placeholder'
|
||||
])
|
||||
|
||||
const wrapper = await createWrapper()
|
||||
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
|
||||
@@ -132,74 +141,6 @@ describe('NodeSearchContent', () => {
|
||||
expect(wrapper.text()).toContain('No results')
|
||||
})
|
||||
|
||||
it('should show only non-Core nodes when Custom is selected', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'CoreNode',
|
||||
display_name: 'Core Node',
|
||||
python_module: 'nodes'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'CustomNode',
|
||||
display_name: 'Custom Node',
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(useNodeDefStore().nodeDefsByName['CoreNode'].nodeSource.type).toBe(
|
||||
NodeSourceType.Core
|
||||
)
|
||||
expect(
|
||||
useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
|
||||
).toBe(NodeSourceType.CustomNodes)
|
||||
|
||||
const wrapper = await createWrapper()
|
||||
await wrapper.find('[data-testid="category-custom"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const items = getNodeItems(wrapper)
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].text()).toContain('Custom Node')
|
||||
})
|
||||
|
||||
it('should hide Essentials category when no essential nodes exist', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'RegularNode',
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
|
||||
const wrapper = await createWrapper()
|
||||
expect(wrapper.find('[data-testid="category-essentials"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('should show only essential nodes when Essentials is selected', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'EssentialNode',
|
||||
display_name: 'Essential Node',
|
||||
essentials_category: 'basic'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'RegularNode',
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const wrapper = await createWrapper()
|
||||
await wrapper.find('[data-testid="category-essentials"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const items = getNodeItems(wrapper)
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].text()).toContain('Essential Node')
|
||||
})
|
||||
|
||||
it('should include subcategory nodes when parent category is selected', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
@@ -230,6 +171,133 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('root filter (filter bar categories)', () => {
|
||||
it('should show only non-Core nodes when Extensions root filter is active', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'CoreNode',
|
||||
display_name: 'Core Node',
|
||||
python_module: 'nodes'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'CustomNode',
|
||||
display_name: 'Custom Node',
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(useNodeDefStore().nodeDefsByName['CoreNode'].nodeSource.type).toBe(
|
||||
NodeSourceType.Core
|
||||
)
|
||||
expect(
|
||||
useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
|
||||
).toBe(NodeSourceType.CustomNodes)
|
||||
|
||||
const wrapper = await createWrapper()
|
||||
// Click the Extensions button in the filter bar
|
||||
const extensionsBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Extensions'))
|
||||
expect(extensionsBtn).toBeTruthy()
|
||||
await extensionsBtn!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const items = getNodeItems(wrapper)
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].text()).toContain('Custom Node')
|
||||
})
|
||||
|
||||
it('should show only essential nodes when Essentials root filter is active', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'EssentialNode',
|
||||
display_name: 'Essential Node',
|
||||
essentials_category: 'basic'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'RegularNode',
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const wrapper = await createWrapper()
|
||||
const essentialsBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Essentials'))
|
||||
expect(essentialsBtn).toBeTruthy()
|
||||
await essentialsBtn!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const items = getNodeItems(wrapper)
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].text()).toContain('Essential Node')
|
||||
})
|
||||
|
||||
it('should show only API nodes when Partner Nodes root filter is active', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ApiNode',
|
||||
display_name: 'API Node',
|
||||
api_node: true
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'RegularNode',
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
|
||||
const wrapper = await createWrapper()
|
||||
const partnerBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Partner'))
|
||||
expect(partnerBtn).toBeTruthy()
|
||||
await partnerBtn!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const items = getNodeItems(wrapper)
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].text()).toContain('API Node')
|
||||
})
|
||||
|
||||
it('should toggle root filter off when clicking the active category button', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'CoreNode',
|
||||
display_name: 'Core Node',
|
||||
python_module: 'nodes'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'CustomNode',
|
||||
display_name: 'Custom Node',
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
|
||||
useNodeDefStore().nodeDefsByName['CoreNode'],
|
||||
useNodeDefStore().nodeDefsByName['CustomNode']
|
||||
])
|
||||
|
||||
const wrapper = await createWrapper()
|
||||
const extensionsBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Extensions'))!
|
||||
|
||||
// Activate
|
||||
await extensionsBtn.trigger('click')
|
||||
await nextTick()
|
||||
expect(getNodeItems(wrapper)).toHaveLength(1)
|
||||
|
||||
// Deactivate (toggle off)
|
||||
await extensionsBtn.trigger('click')
|
||||
await nextTick()
|
||||
expect(getNodeItems(wrapper)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('search and category interaction', () => {
|
||||
it('should override category to most-relevant when search query is active', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
@@ -263,6 +331,9 @@ describe('NodeSearchContent', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'TestNode', display_name: 'Test Node' })
|
||||
])
|
||||
vi.spyOn(useNodeBookmarkStore(), 'bookmarks', 'get').mockReturnValue([
|
||||
'placeholder'
|
||||
])
|
||||
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
@@ -380,7 +451,7 @@ describe('NodeSearchContent', () => {
|
||||
])
|
||||
|
||||
const results = getResultItems(wrapper)
|
||||
await results[1].trigger('mouseenter')
|
||||
await results[1].trigger('pointermove')
|
||||
await nextTick()
|
||||
|
||||
expect(results[1].attributes('aria-selected')).toBe('true')
|
||||
@@ -413,9 +484,13 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
|
||||
it('should emit null hoverNode when no results', async () => {
|
||||
const bookmarkStore = useNodeBookmarkStore()
|
||||
vi.spyOn(bookmarkStore, 'isBookmarked').mockReturnValue(false)
|
||||
vi.spyOn(bookmarkStore, 'bookmarks', 'get').mockReturnValue([
|
||||
'placeholder'
|
||||
])
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
|
||||
await wrapper.find('[data-testid="category-favorites"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
@@ -509,221 +584,4 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter selection mode', () => {
|
||||
function setupNodesWithTypes() {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ImageNode',
|
||||
display_name: 'Image Node',
|
||||
input: { required: { image: ['IMAGE', {}] } },
|
||||
output: ['IMAGE']
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'LatentNode',
|
||||
display_name: 'Latent Node',
|
||||
input: { required: { latent: ['LATENT', {}] } },
|
||||
output: ['LATENT']
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'ModelNode',
|
||||
display_name: 'Model Node',
|
||||
input: { required: { model: ['MODEL', {}] } },
|
||||
output: ['MODEL']
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
function findFilterBarButton(wrapper: VueWrapper, label: string) {
|
||||
return wrapper
|
||||
.findAll('button[aria-pressed]')
|
||||
.find((b) => b.text() === label)
|
||||
}
|
||||
|
||||
async function enterFilterMode(wrapper: VueWrapper) {
|
||||
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function getFilterOptions(wrapper: VueWrapper) {
|
||||
return wrapper.findAll('[data-testid="filter-option"]')
|
||||
}
|
||||
|
||||
function getFilterOptionTexts(wrapper: VueWrapper) {
|
||||
return getFilterOptions(wrapper).map(
|
||||
(o) =>
|
||||
o
|
||||
.findAll('span')[0]
|
||||
?.text()
|
||||
.replace(/^[•·]\s*/, '')
|
||||
.trim() ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function hasSidebar(wrapper: VueWrapper) {
|
||||
return wrapper.findComponent(NodeSearchCategorySidebar).exists()
|
||||
}
|
||||
|
||||
it('should enter filter mode when a filter chip is selected', async () => {
|
||||
setupNodesWithTypes()
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
expect(hasSidebar(wrapper)).toBe(true)
|
||||
|
||||
await enterFilterMode(wrapper)
|
||||
|
||||
expect(hasSidebar(wrapper)).toBe(false)
|
||||
expect(getFilterOptions(wrapper).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show available filter options sorted alphabetically', async () => {
|
||||
setupNodesWithTypes()
|
||||
const wrapper = await createWrapper()
|
||||
await enterFilterMode(wrapper)
|
||||
|
||||
const texts = getFilterOptionTexts(wrapper)
|
||||
expect(texts).toContain('IMAGE')
|
||||
expect(texts).toContain('LATENT')
|
||||
expect(texts).toContain('MODEL')
|
||||
expect(texts).toEqual([...texts].sort())
|
||||
})
|
||||
|
||||
it('should filter options when typing in filter mode', async () => {
|
||||
setupNodesWithTypes()
|
||||
const wrapper = await createWrapper()
|
||||
await enterFilterMode(wrapper)
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('IMAGE')
|
||||
await nextTick()
|
||||
|
||||
const texts = getFilterOptionTexts(wrapper)
|
||||
expect(texts).toContain('IMAGE')
|
||||
expect(texts).not.toContain('MODEL')
|
||||
})
|
||||
|
||||
it('should show no results when filter query has no matches', async () => {
|
||||
setupNodesWithTypes()
|
||||
const wrapper = await createWrapper()
|
||||
await enterFilterMode(wrapper)
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('NONEXISTENT_TYPE')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('No results')
|
||||
})
|
||||
|
||||
it('should emit addFilter when a filter option is clicked', async () => {
|
||||
setupNodesWithTypes()
|
||||
const wrapper = await createWrapper()
|
||||
await enterFilterMode(wrapper)
|
||||
|
||||
const imageOption = getFilterOptions(wrapper).find((o) =>
|
||||
o.text().includes('IMAGE')
|
||||
)
|
||||
await imageOption!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
|
||||
filterDef: expect.objectContaining({ id: 'input' }),
|
||||
value: 'IMAGE'
|
||||
})
|
||||
})
|
||||
|
||||
it('should exit filter mode after applying a filter', async () => {
|
||||
setupNodesWithTypes()
|
||||
const wrapper = await createWrapper()
|
||||
await enterFilterMode(wrapper)
|
||||
|
||||
await getFilterOptions(wrapper)[0].trigger('click')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(hasSidebar(wrapper)).toBe(true)
|
||||
})
|
||||
|
||||
it('should emit addFilter when Enter is pressed on selected option', async () => {
|
||||
setupNodesWithTypes()
|
||||
const wrapper = await createWrapper()
|
||||
await enterFilterMode(wrapper)
|
||||
|
||||
await wrapper
|
||||
.find('input[type="text"]')
|
||||
.trigger('keydown', { key: 'Enter' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
|
||||
filterDef: expect.objectContaining({ id: 'input' }),
|
||||
value: 'IMAGE'
|
||||
})
|
||||
})
|
||||
|
||||
it('should navigate filter options with ArrowDown/ArrowUp', async () => {
|
||||
setupNodesWithTypes()
|
||||
const wrapper = await createWrapper()
|
||||
await enterFilterMode(wrapper)
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
|
||||
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
|
||||
'true'
|
||||
)
|
||||
|
||||
await input.trigger('keydown', { key: 'ArrowDown' })
|
||||
await nextTick()
|
||||
expect(getFilterOptions(wrapper)[1].attributes('aria-selected')).toBe(
|
||||
'true'
|
||||
)
|
||||
|
||||
await input.trigger('keydown', { key: 'ArrowUp' })
|
||||
await nextTick()
|
||||
expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('should toggle filter mode off when same chip is clicked again', async () => {
|
||||
setupNodesWithTypes()
|
||||
const wrapper = await createWrapper()
|
||||
await enterFilterMode(wrapper)
|
||||
|
||||
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(hasSidebar(wrapper)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reset filter query when re-entering filter mode', async () => {
|
||||
setupNodesWithTypes()
|
||||
const wrapper = await createWrapper()
|
||||
await enterFilterMode(wrapper)
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
await input.setValue('IMAGE')
|
||||
await nextTick()
|
||||
|
||||
await findFilterBarButton(wrapper, 'Input')!.trigger('click')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
await enterFilterMode(wrapper)
|
||||
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('should exit filter mode when cancel button is clicked', async () => {
|
||||
setupNodesWithTypes()
|
||||
const wrapper = await createWrapper()
|
||||
await enterFilterMode(wrapper)
|
||||
|
||||
expect(hasSidebar(wrapper)).toBe(false)
|
||||
|
||||
const cancelBtn = wrapper.find('[data-testid="cancel-filter"]')
|
||||
await cancelBtn.trigger('click')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(hasSidebar(wrapper)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,52 +7,45 @@
|
||||
<NodeSearchInput
|
||||
ref="searchInputRef"
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:filter-query="filterQuery"
|
||||
:filters="filters"
|
||||
:active-filter="activeFilter"
|
||||
@remove-filter="emit('removeFilter', $event)"
|
||||
@cancel-filter="cancelFilter"
|
||||
@navigate-down="onKeyDown"
|
||||
@navigate-up="onKeyUp"
|
||||
@select-current="onKeyEnter"
|
||||
@navigate-down="navigateResults(1)"
|
||||
@navigate-up="navigateResults(-1)"
|
||||
@select-current="selectCurrentResult"
|
||||
/>
|
||||
|
||||
<!-- Filter header row -->
|
||||
<div class="flex items-center">
|
||||
<div class="shrink-0 px-3 py-2 text-sm text-muted-foreground">
|
||||
{{ $t('g.filterBy') }}
|
||||
</div>
|
||||
<NodeSearchFilterBar
|
||||
class="flex-1"
|
||||
:active-chip-key="activeFilter?.key"
|
||||
@select-chip="onSelectFilterChip"
|
||||
:filters="filters"
|
||||
:active-category="rootFilter"
|
||||
@toggle-filter="onToggleFilter"
|
||||
@clear-filter-group="onClearFilterGroup"
|
||||
@focus-search="nextTick(() => searchInputRef?.focus())"
|
||||
@select-category="onSelectCategory"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<!-- Category sidebar (hidden in filter mode) -->
|
||||
<!-- Category sidebar -->
|
||||
<NodeSearchCategorySidebar
|
||||
v-if="!activeFilter"
|
||||
v-model:selected-category="sidebarCategory"
|
||||
class="w-52 shrink-0"
|
||||
:hide-chevrons="!anyTreeCategoryHasChildren"
|
||||
:hide-presets="rootFilter !== null"
|
||||
:node-defs="rootFilteredNodeDefs"
|
||||
:root-label="rootFilterLabel"
|
||||
:root-key="rootFilter ?? undefined"
|
||||
/>
|
||||
|
||||
<!-- Filter options list (filter selection mode) -->
|
||||
<NodeSearchFilterPanel
|
||||
v-if="activeFilter"
|
||||
ref="filterPanelRef"
|
||||
v-model:query="filterQuery"
|
||||
:chip="activeFilter"
|
||||
@apply="onFilterApply"
|
||||
/>
|
||||
|
||||
<!-- Results list (normal mode) -->
|
||||
<!-- Results list -->
|
||||
<div
|
||||
v-else
|
||||
id="results-list"
|
||||
role="listbox"
|
||||
class="flex-1 overflow-y-auto py-2"
|
||||
@pointermove="onPointerMove"
|
||||
>
|
||||
<div
|
||||
v-for="(node, index) in displayedResults"
|
||||
@@ -68,13 +61,12 @@
|
||||
)
|
||||
"
|
||||
@click="emit('addNode', node, $event)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
>
|
||||
<NodeSearchListItem
|
||||
:node-def="node"
|
||||
:current-query="searchQuery"
|
||||
show-description
|
||||
:show-source-badge="effectiveCategory !== 'essentials'"
|
||||
:show-source-badge="rootFilter !== 'essentials'"
|
||||
:hide-bookmark-icon="effectiveCategory === 'favorites'"
|
||||
/>
|
||||
</div>
|
||||
@@ -90,19 +82,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
|
||||
import NodeSearchFilterBar from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
|
||||
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
|
||||
import NodeSearchFilterPanel from '@/components/searchbox/v2/NodeSearchFilterPanel.vue'
|
||||
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
|
||||
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { isCustomNode, isEssentialNode } from '@/types/nodeSource'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { filters } = defineProps<{
|
||||
@@ -116,58 +107,89 @@ const emit = defineEmits<{
|
||||
hoverNode: [nodeDef: ComfyNodeDefImpl | null]
|
||||
}>()
|
||||
|
||||
const BLUEPRINT_CATEGORY = 'Subgraph Blueprints'
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeFrequencyStore = useNodeFrequencyStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
const dialogRef = ref<HTMLElement>()
|
||||
const searchInputRef = ref<InstanceType<typeof NodeSearchInput>>()
|
||||
const filterPanelRef = ref<InstanceType<typeof NodeSearchFilterPanel>>()
|
||||
|
||||
onMounted(() => {
|
||||
if (dialogRef.value) {
|
||||
dialogRef.value.style.height = `${dialogRef.value.offsetHeight}px`
|
||||
}
|
||||
})
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('most-relevant')
|
||||
const selectedIndex = ref(0)
|
||||
|
||||
// Filter selection mode
|
||||
const activeFilter = ref<FilterChip | null>(null)
|
||||
const filterQuery = ref('')
|
||||
// Root filter from filter bar category buttons (radio toggle)
|
||||
const rootFilter = ref<string | null>(null)
|
||||
|
||||
function lockDialogHeight() {
|
||||
if (dialogRef.value) {
|
||||
dialogRef.value.style.height = `${dialogRef.value.offsetHeight}px`
|
||||
const rootFilterLabel = computed(() => {
|
||||
switch (rootFilter.value) {
|
||||
case BLUEPRINT_CATEGORY:
|
||||
return t('g.blueprints')
|
||||
case 'partner-nodes':
|
||||
return t('g.partnerNodes')
|
||||
case 'essentials':
|
||||
return t('g.essentials')
|
||||
case 'custom':
|
||||
return t('g.extensions')
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
const rootFilteredNodeDefs = computed(() => {
|
||||
if (!rootFilter.value) return nodeDefStore.visibleNodeDefs
|
||||
const allNodes = nodeDefStore.visibleNodeDefs
|
||||
switch (rootFilter.value) {
|
||||
case BLUEPRINT_CATEGORY:
|
||||
return allNodes.filter((n) => n.category.startsWith(rootFilter.value!))
|
||||
case 'partner-nodes':
|
||||
return allNodes.filter((n) => n.api_node)
|
||||
case 'essentials':
|
||||
return allNodes.filter(isEssentialNode)
|
||||
case 'custom':
|
||||
return allNodes.filter(isCustomNode)
|
||||
default:
|
||||
return allNodes
|
||||
}
|
||||
})
|
||||
|
||||
function onToggleFilter(
|
||||
filterDef: FuseFilter<ComfyNodeDefImpl, string>,
|
||||
value: string
|
||||
) {
|
||||
const existing = filters.find(
|
||||
(f) => f.filterDef.id === filterDef.id && f.value === value
|
||||
)
|
||||
if (existing) {
|
||||
emit('removeFilter', existing)
|
||||
} else {
|
||||
emit('addFilter', { filterDef, value })
|
||||
}
|
||||
}
|
||||
|
||||
function unlockDialogHeight() {
|
||||
if (dialogRef.value) {
|
||||
dialogRef.value.style.height = ''
|
||||
function onClearFilterGroup(filterId: string) {
|
||||
for (const f of filters.filter((f) => f.filterDef.id === filterId)) {
|
||||
emit('removeFilter', f)
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectFilterChip(chip: FilterChip) {
|
||||
if (activeFilter.value?.key === chip.key) {
|
||||
cancelFilter()
|
||||
return
|
||||
function onSelectCategory(category: string) {
|
||||
if (rootFilter.value === category) {
|
||||
rootFilter.value = null
|
||||
} else {
|
||||
rootFilter.value = category
|
||||
}
|
||||
lockDialogHeight()
|
||||
activeFilter.value = chip
|
||||
filterQuery.value = ''
|
||||
nextTick(() => searchInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function onFilterApply(value: string) {
|
||||
if (!activeFilter.value) return
|
||||
emit('addFilter', { filterDef: activeFilter.value.filter, value })
|
||||
activeFilter.value = null
|
||||
filterQuery.value = ''
|
||||
unlockDialogHeight()
|
||||
nextTick(() => searchInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function cancelFilter() {
|
||||
activeFilter.value = null
|
||||
filterQuery.value = ''
|
||||
unlockDialogHeight()
|
||||
selectedCategory.value = 'most-relevant'
|
||||
searchQuery.value = ''
|
||||
nextTick(() => searchInputRef.value?.focus())
|
||||
}
|
||||
|
||||
@@ -197,35 +219,55 @@ function matchesFilters(node: ComfyNodeDefImpl): boolean {
|
||||
return filters.every(({ filterDef, value }) => filterDef.matches(node, value))
|
||||
}
|
||||
|
||||
// Check if any tree category has children (for chevron visibility)
|
||||
const anyTreeCategoryHasChildren = computed(() =>
|
||||
rootFilteredNodeDefs.value.some((n) => n.category.includes('/'))
|
||||
)
|
||||
|
||||
const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
|
||||
const allNodes = nodeDefStore.visibleNodeDefs
|
||||
const baseNodes = rootFilteredNodeDefs.value
|
||||
|
||||
let results: ComfyNodeDefImpl[]
|
||||
switch (effectiveCategory.value) {
|
||||
case 'most-relevant':
|
||||
return searchResults.value
|
||||
case 'most-relevant': {
|
||||
if (searchQuery.value || filters.length > 0) {
|
||||
const searched = searchResults.value
|
||||
if (rootFilter.value) {
|
||||
const rootSet = new Set(baseNodes.map((n) => n.name))
|
||||
return searched.filter((n) => rootSet.has(n.name))
|
||||
}
|
||||
return searched
|
||||
}
|
||||
if (rootFilter.value) {
|
||||
return baseNodes
|
||||
}
|
||||
return nodeFrequencyStore.topNodeDefs
|
||||
}
|
||||
case 'favorites':
|
||||
results = allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
|
||||
results = baseNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
|
||||
break
|
||||
case 'essentials':
|
||||
results = allNodes.filter(
|
||||
(n) => n.nodeSource.type === NodeSourceType.Essentials
|
||||
)
|
||||
results = baseNodes.filter(isEssentialNode)
|
||||
break
|
||||
case 'custom':
|
||||
results = allNodes.filter(
|
||||
(n) =>
|
||||
n.nodeSource.type !== NodeSourceType.Core &&
|
||||
n.nodeSource.type !== NodeSourceType.Essentials
|
||||
)
|
||||
results = baseNodes.filter(isCustomNode)
|
||||
break
|
||||
default:
|
||||
results = allNodes.filter(
|
||||
(n) =>
|
||||
n.category === effectiveCategory.value ||
|
||||
n.category.startsWith(effectiveCategory.value + '/')
|
||||
)
|
||||
default: {
|
||||
if (rootFilter.value && effectiveCategory.value === rootFilter.value) {
|
||||
results = baseNodes
|
||||
} else {
|
||||
const rootPrefix = rootFilter.value ? rootFilter.value + '/' : ''
|
||||
const categoryPath = effectiveCategory.value.startsWith(rootPrefix)
|
||||
? effectiveCategory.value.slice(rootPrefix.length)
|
||||
: effectiveCategory.value
|
||||
results = baseNodes.filter(
|
||||
(n) =>
|
||||
n.category === categoryPath ||
|
||||
n.category.startsWith(categoryPath + '/')
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return filters.length > 0 ? results.filter(matchesFilters) : results
|
||||
@@ -243,35 +285,19 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch([selectedCategory, searchQuery, () => filters], () => {
|
||||
watch([selectedCategory, searchQuery, rootFilter, () => filters], () => {
|
||||
selectedIndex.value = 0
|
||||
})
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
const item = (event.target as HTMLElement).closest('[role=option]')
|
||||
if (!item) return
|
||||
const index = Number(item.id.replace('result-item-', ''))
|
||||
if (!isNaN(index) && index !== selectedIndex.value)
|
||||
selectedIndex.value = index
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
function onKeyDown() {
|
||||
if (activeFilter.value) {
|
||||
filterPanelRef.value?.navigate(1)
|
||||
} else {
|
||||
navigateResults(1)
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyUp() {
|
||||
if (activeFilter.value) {
|
||||
filterPanelRef.value?.navigate(-1)
|
||||
} else {
|
||||
navigateResults(-1)
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyEnter() {
|
||||
if (activeFilter.value) {
|
||||
filterPanelRef.value?.selectCurrent()
|
||||
} else {
|
||||
selectCurrentResult()
|
||||
}
|
||||
}
|
||||
|
||||
function navigateResults(direction: number) {
|
||||
const newIndex = selectedIndex.value + direction
|
||||
if (newIndex >= 0 && newIndex < displayedResults.value.length) {
|
||||
|
||||
@@ -33,48 +33,72 @@ describe(NodeSearchFilterBar, () => {
|
||||
async function createWrapper(props = {}) {
|
||||
const wrapper = mount(NodeSearchFilterBar, {
|
||||
props,
|
||||
global: { plugins: [testI18n] }
|
||||
global: {
|
||||
plugins: [testI18n],
|
||||
stubs: {
|
||||
NodeSearchTypeFilterPopover: {
|
||||
template: '<div data-testid="popover"><slot /></div>',
|
||||
props: ['chip', 'selectedValues']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
await nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('should render Input, Output, and Source filter chips', async () => {
|
||||
it('should render Extensions button and Input/Output popover triggers', async () => {
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
expect(buttons[0].text()).toBe('Input')
|
||||
expect(buttons[1].text()).toBe('Output')
|
||||
expect(buttons[2].text()).toBe('Source')
|
||||
const texts = buttons.map((b) => b.text())
|
||||
expect(texts).toContain('Extensions')
|
||||
expect(texts).toContain('Input')
|
||||
expect(texts).toContain('Output')
|
||||
})
|
||||
|
||||
it('should mark active chip as pressed when activeChipKey matches', async () => {
|
||||
const wrapper = await createWrapper({ activeChipKey: 'input' })
|
||||
it('should render conditional category buttons when matching nodes exist', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'BlueprintNode',
|
||||
category: 'Subgraph Blueprints/MyBlueprint'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'ApiNode',
|
||||
api_node: true
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'EssentialNode',
|
||||
essentials_category: 'basic'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
|
||||
expect(inputBtn?.attributes('aria-pressed')).toBe('true')
|
||||
const wrapper = await createWrapper()
|
||||
const texts = wrapper.findAll('button').map((b) => b.text())
|
||||
expect(texts).toContain('Blueprints')
|
||||
expect(texts).toContain('Partner Nodes')
|
||||
expect(texts).toContain('Essentials')
|
||||
})
|
||||
|
||||
it('should not mark chips as pressed when activeChipKey does not match', async () => {
|
||||
const wrapper = await createWrapper({ activeChipKey: null })
|
||||
|
||||
wrapper.findAll('button').forEach((btn) => {
|
||||
expect(btn.attributes('aria-pressed')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit selectChip with chip data when clicked', async () => {
|
||||
it('should emit selectCategory when category button is clicked', async () => {
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
|
||||
await inputBtn?.trigger('click')
|
||||
const extensionsBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text() === 'Extensions')!
|
||||
await extensionsBtn.trigger('click')
|
||||
|
||||
const emitted = wrapper.emitted('selectChip')!
|
||||
expect(emitted[0][0]).toMatchObject({
|
||||
key: 'input',
|
||||
label: 'Input',
|
||||
filter: expect.anything()
|
||||
})
|
||||
expect(wrapper.emitted('selectCategory')![0]).toEqual(['custom'])
|
||||
})
|
||||
|
||||
it('should apply active styling when activeCategory matches', async () => {
|
||||
const wrapper = await createWrapper({ activeCategory: 'custom' })
|
||||
|
||||
const extensionsBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text() === 'Extensions')!
|
||||
|
||||
expect(extensionsBtn.classes()).toContain('bg-base-foreground')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 px-2 py-1.5">
|
||||
<!-- Category filter buttons -->
|
||||
<button
|
||||
v-for="chip in chips"
|
||||
:key="chip.key"
|
||||
v-for="chip in categoryChips"
|
||||
:key="chip.category"
|
||||
type="button"
|
||||
:aria-pressed="activeChipKey === chip.key"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded-md border px-3 py-1 text-sm transition-colors flex-auto border-secondary-background',
|
||||
activeChipKey === chip.key
|
||||
? 'bg-secondary-background text-foreground'
|
||||
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
|
||||
)
|
||||
"
|
||||
@click="emit('selectChip', chip)"
|
||||
:aria-pressed="activeCategory === chip.category"
|
||||
:class="chipClass(activeCategory === chip.category)"
|
||||
@click="emit('selectCategory', chip.category)"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</button>
|
||||
|
||||
<!-- Type filter popovers (input/output) -->
|
||||
<NodeSearchTypeFilterPopover
|
||||
v-for="{ chip, selectedValues } in typeFilterChips"
|
||||
:key="chip.key"
|
||||
:chip="chip"
|
||||
:selected-values="selectedValues"
|
||||
@toggle="(v) => emit('toggleFilter', chip.filter, v)"
|
||||
@clear="emit('clearFilterGroup', chip.filter.id)"
|
||||
@escape-close="emit('focusSearch')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="chipClass(false, selectedValues.length > 0)"
|
||||
>
|
||||
<span v-if="selectedValues.length > 0" class="flex items-center">
|
||||
<span
|
||||
v-for="val in selectedValues.slice(0, 4)"
|
||||
:key="val"
|
||||
class="text-lg leading-none -mx-[2px]"
|
||||
:style="{ color: getLinkTypeColor(val) }"
|
||||
>•</span
|
||||
>
|
||||
</span>
|
||||
{{ chip.label }}
|
||||
<i class="icon-[lucide--chevron-down] size-3.5" />
|
||||
</button>
|
||||
</NodeSearchTypeFilterPopover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -35,38 +57,70 @@ export interface FilterChip {
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { getLinkTypeColor } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { activeChipKey = null } = defineProps<{
|
||||
activeChipKey?: string | null
|
||||
const { filters = [], activeCategory = null } = defineProps<{
|
||||
filters?: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
activeCategory?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectChip: [chip: FilterChip]
|
||||
toggleFilter: [filterDef: FuseFilter<ComfyNodeDefImpl, string>, value: string]
|
||||
clearFilterGroup: [filterId: string]
|
||||
focusSearch: []
|
||||
selectCategory: [category: string]
|
||||
}>()
|
||||
|
||||
const BLUEPRINT_CATEGORY = 'Subgraph Blueprints'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const chips = computed<FilterChip[]>(() => {
|
||||
const searchService = nodeDefStore.nodeSearchService
|
||||
const typeFilterChips = computed(() => {
|
||||
const { inputTypeFilter, outputTypeFilter } = nodeDefStore.nodeSearchService
|
||||
return [
|
||||
{
|
||||
key: 'input',
|
||||
label: t('g.input'),
|
||||
filter: searchService.inputTypeFilter
|
||||
},
|
||||
{
|
||||
key: 'output',
|
||||
label: t('g.output'),
|
||||
filter: searchService.outputTypeFilter
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: t('g.source'),
|
||||
filter: searchService.nodeSourceFilter
|
||||
}
|
||||
]
|
||||
{ key: 'input', label: t('g.input'), filter: inputTypeFilter },
|
||||
{ key: 'output', label: t('g.output'), filter: outputTypeFilter }
|
||||
].map((chip) => ({
|
||||
chip,
|
||||
selectedValues: filters
|
||||
.filter((f) => f.filterDef.id === chip.key)
|
||||
.map((f) => f.value)
|
||||
}))
|
||||
})
|
||||
|
||||
const categoryChips = computed(() => {
|
||||
const chips: { category: string; label: string }[] = []
|
||||
const defs = nodeDefStore.visibleNodeDefs
|
||||
if (defs.some((n) => n.category.startsWith(BLUEPRINT_CATEGORY)))
|
||||
chips.push({ category: BLUEPRINT_CATEGORY, label: t('g.blueprints') })
|
||||
if (defs.some((n) => n.api_node))
|
||||
chips.push({ category: 'partner-nodes', label: t('g.partnerNodes') })
|
||||
if (
|
||||
flags.nodeLibraryEssentialsEnabled &&
|
||||
defs.some((n) => n.nodeSource.type === NodeSourceType.Essentials)
|
||||
)
|
||||
chips.push({ category: 'essentials', label: t('g.essentials') })
|
||||
chips.push({ category: 'custom', label: t('g.extensions') })
|
||||
return chips
|
||||
})
|
||||
|
||||
function chipClass(isActive: boolean, forceHover = false) {
|
||||
return cn(
|
||||
'flex cursor-pointer items-center justify-center gap-1 rounded-md border px-3 py-1 text-sm transition-colors border-secondary-background',
|
||||
isActive
|
||||
? 'bg-base-foreground text-base-background border-base-foreground'
|
||||
: forceHover
|
||||
? 'bg-transparent border-base-foreground/60 text-base-foreground/60 hover:border-base-foreground/60 hover:text-base-foreground/60'
|
||||
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
id="filter-options-list"
|
||||
ref="listRef"
|
||||
role="listbox"
|
||||
class="flex-1 overflow-y-auto py-2"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:id="`filter-option-${index}`"
|
||||
:key="option"
|
||||
role="option"
|
||||
data-testid="filter-option"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer px-6 py-1.5',
|
||||
index === selectedIndex && 'bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="emit('apply', option)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
>
|
||||
<span class="text-base font-semibold text-foreground">
|
||||
<span class="text-2xl mr-1" :style="{ color: getLinkTypeColor(option) }"
|
||||
>•</span
|
||||
>
|
||||
{{ option }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="options.length === 0"
|
||||
class="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{{ $t('g.noResults') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
|
||||
import { getLinkTypeColor } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { chip } = defineProps<{
|
||||
chip: FilterChip
|
||||
}>()
|
||||
|
||||
const query = defineModel<string>('query', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
apply: [value: string]
|
||||
}>()
|
||||
|
||||
const listRef = ref<HTMLElement>()
|
||||
const selectedIndex = ref(0)
|
||||
|
||||
const options = computed(() => {
|
||||
const { fuseSearch } = chip.filter
|
||||
if (query.value) {
|
||||
return fuseSearch.search(query.value).slice(0, 64)
|
||||
}
|
||||
return fuseSearch.data.slice().sort()
|
||||
})
|
||||
|
||||
watch(query, () => {
|
||||
selectedIndex.value = 0
|
||||
})
|
||||
|
||||
function navigate(direction: number) {
|
||||
const newIndex = selectedIndex.value + direction
|
||||
if (newIndex >= 0 && newIndex < options.value.length) {
|
||||
selectedIndex.value = newIndex
|
||||
nextTick(() => {
|
||||
listRef.value
|
||||
?.querySelector(`#filter-option-${newIndex}`)
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function selectCurrent() {
|
||||
const option = options.value[selectedIndex.value]
|
||||
if (option) emit('apply', option)
|
||||
}
|
||||
|
||||
defineExpose({ navigate, selectCurrent })
|
||||
</script>
|
||||
@@ -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,
|
||||
@@ -39,20 +38,6 @@ function createFilter(
|
||||
}
|
||||
}
|
||||
|
||||
function createActiveFilter(label: string): FilterChip {
|
||||
return {
|
||||
key: label.toLowerCase(),
|
||||
label,
|
||||
filter: {
|
||||
id: label.toLowerCase(),
|
||||
matches: vi.fn(() => true)
|
||||
} as Partial<FuseFilter<ComfyNodeDefImpl, string>> as FuseFilter<
|
||||
ComfyNodeDefImpl,
|
||||
string
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
describe('NodeSearchInput', () => {
|
||||
beforeEach(() => {
|
||||
setupTestPinia()
|
||||
@@ -62,51 +47,27 @@ describe('NodeSearchInput', () => {
|
||||
function createWrapper(
|
||||
props: Partial<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
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 +75,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', () => {
|
||||
const wrapper = createWrapper({
|
||||
filters: [createFilter('input', 'IMAGE')]
|
||||
})
|
||||
@@ -131,16 +83,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()
|
||||
|
||||
|
||||
@@ -7,60 +7,40 @@
|
||||
@remove-tag="onRemoveTag"
|
||||
@click="inputRef?.focus()"
|
||||
>
|
||||
<!-- Active filter label (filter selection mode) -->
|
||||
<span
|
||||
v-if="activeFilter"
|
||||
class="inline-flex shrink-0 items-center gap-1 rounded-lg bg-base-background px-2 py-1 -my-1 text-sm opacity-80 text-foreground"
|
||||
<!-- Applied filter chips -->
|
||||
<TagsInputItem
|
||||
v-for="filter in filters"
|
||||
:key="filterKey(filter)"
|
||||
:value="filterKey(filter)"
|
||||
data-testid="filter-chip"
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-base-background px-2 py-1 -my-1 data-[state=active]:ring-2 data-[state=active]:ring-primary"
|
||||
>
|
||||
{{ activeFilter.label }}:
|
||||
<button
|
||||
<span class="text-sm opacity-80">
|
||||
{{ t(`g.${filter.filterDef.id}`) }}:
|
||||
</span>
|
||||
<span :style="{ color: getLinkTypeColor(filter.value) }"> • </span>
|
||||
<span class="text-sm">{{ filter.value }}</span>
|
||||
<TagsInputItemDelete
|
||||
as="button"
|
||||
type="button"
|
||||
data-testid="cancel-filter"
|
||||
class="cursor-pointer border-none bg-transparent text-muted-foreground hover:text-base-foreground rounded-full aspect-square"
|
||||
data-testid="chip-delete"
|
||||
:aria-label="$t('g.remove')"
|
||||
@click="emit('cancelFilter')"
|
||||
class="ml-1 cursor-pointer border-none bg-transparent text-muted-foreground hover:text-base-foreground rounded-full aspect-square"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</button>
|
||||
</span>
|
||||
<!-- Applied filter chips -->
|
||||
<template v-if="!activeFilter">
|
||||
<TagsInputItem
|
||||
v-for="filter in filters"
|
||||
:key="filterKey(filter)"
|
||||
:value="filterKey(filter)"
|
||||
data-testid="filter-chip"
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-base-background px-2 py-1 -my-1 data-[state=active]:ring-2 data-[state=active]:ring-primary"
|
||||
>
|
||||
<span class="text-sm opacity-80">
|
||||
{{ t(`g.${filter.filterDef.id}`) }}:
|
||||
</span>
|
||||
<span :style="{ color: getLinkTypeColor(filter.value) }">
|
||||
•
|
||||
</span>
|
||||
<span class="text-sm">{{ filter.value }}</span>
|
||||
<TagsInputItemDelete
|
||||
as="button"
|
||||
type="button"
|
||||
data-testid="chip-delete"
|
||||
:aria-label="$t('g.remove')"
|
||||
class="ml-1 cursor-pointer border-none bg-transparent text-muted-foreground hover:text-base-foreground rounded-full aspect-square"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</TagsInputItemDelete>
|
||||
</TagsInputItem>
|
||||
</template>
|
||||
</TagsInputItemDelete>
|
||||
</TagsInputItem>
|
||||
<TagsInputInput as-child>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
:aria-expanded="true"
|
||||
:aria-controls="activeFilter ? 'filter-options-list' : 'results-list'"
|
||||
:aria-label="inputPlaceholder"
|
||||
:placeholder="inputPlaceholder"
|
||||
aria-controls="results-list"
|
||||
:aria-label="t('g.addNode')"
|
||||
:placeholder="t('g.addNode')"
|
||||
class="h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent text-foreground text-sm outline-none placeholder:text-muted-foreground"
|
||||
@keydown.enter.prevent="emit('selectCurrent')"
|
||||
@keydown.down.prevent="emit('navigateDown')"
|
||||
@@ -81,22 +61,18 @@ import {
|
||||
TagsInputRoot
|
||||
} from 'reka-ui'
|
||||
|
||||
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { getLinkTypeColor } from '@/utils/litegraphUtil'
|
||||
|
||||
const { filters, activeFilter } = defineProps<{
|
||||
const { filters } = defineProps<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
activeFilter: FilterChip | null
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery', { required: true })
|
||||
const filterQuery = defineModel<string>('filterQuery', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
removeFilter: [filter: FuseFilterWithValue<ComfyNodeDefImpl, string>]
|
||||
cancelFilter: []
|
||||
navigateDown: []
|
||||
navigateUp: []
|
||||
selectCurrent: []
|
||||
@@ -105,23 +81,6 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const inputRef = ref<HTMLInputElement>()
|
||||
|
||||
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<ComfyNodeDefImpl, string>) {
|
||||
|
||||
@@ -2,36 +2,64 @@
|
||||
<div
|
||||
class="option-container flex w-full cursor-pointer items-center justify-between overflow-hidden"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 overflow-hidden">
|
||||
<div class="font-semibold text-foreground flex items-center gap-2">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5 overflow-hidden">
|
||||
<!-- Row 1: Name (left) + badges (right) -->
|
||||
<div class="flex items-center gap-2 font-semibold text-foreground">
|
||||
<span v-if="isBookmarked && !hideBookmarkIcon">
|
||||
<i class="pi pi-bookmark-fill mr-1 text-sm" />
|
||||
</span>
|
||||
<span v-html="highlightQuery(nodeDef.display_name, currentQuery)" />
|
||||
<span
|
||||
class="truncate"
|
||||
v-html="highlightQuery(nodeDef.display_name, currentQuery)"
|
||||
/>
|
||||
<span v-if="showIdName"> </span>
|
||||
<span
|
||||
v-if="showIdName"
|
||||
class="rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
class="shrink-0 rounded bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
v-html="highlightQuery(nodeDef.name, currentQuery)"
|
||||
/>
|
||||
|
||||
<NodePricingBadge :node-def="nodeDef" />
|
||||
<NodeProviderBadge v-if="nodeDef.api_node" :node-def="nodeDef" />
|
||||
<template v-if="showDescription">
|
||||
<div class="flex-1" />
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<span
|
||||
v-if="showSourceBadge && !isCustomNode"
|
||||
aria-hidden="true"
|
||||
class="flex size-[18px] shrink-0 items-center justify-center rounded-full bg-secondary-background-selected"
|
||||
>
|
||||
<ComfyLogo :size="10" mode="fill" color="currentColor" />
|
||||
</span>
|
||||
<span
|
||||
v-else-if="showSourceBadge && isCustomNode"
|
||||
:class="badgePillClass"
|
||||
>
|
||||
<span class="truncate text-[10px]">
|
||||
{{ nodeDef.nodeSource.displayText }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="nodeDef.api_node && providerName"
|
||||
:class="badgePillClass"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--component] size-3 text-amber-400"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="cn(getProviderIcon(providerName), 'size-3')"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NodePricingBadge :node-def="nodeDef" />
|
||||
<NodeProviderBadge v-if="nodeDef.api_node" :node-def="nodeDef" />
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="showDescription"
|
||||
class="flex items-center gap-1 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
showSourceBadge &&
|
||||
nodeDef.nodeSource.type !== NodeSourceType.Core &&
|
||||
nodeDef.nodeSource.type !== NodeSourceType.Unknown
|
||||
"
|
||||
class="inline-flex shrink-0 rounded border border-border px-1.5 py-0.5 text-xs bg-base-foreground/5 text-base-foreground/70 mr-0.5"
|
||||
>
|
||||
{{ nodeDef.nodeSource.displayText }}
|
||||
</span>
|
||||
|
||||
<div v-if="showDescription" class="text-[11px] text-muted-foreground">
|
||||
<TextTicker v-if="nodeDef.description">
|
||||
{{ nodeDef.description }}
|
||||
</TextTicker>
|
||||
@@ -82,14 +110,20 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import TextTicker from '@/components/common/TextTicker.vue'
|
||||
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
|
||||
import NodePricingBadge from '@/components/node/NodePricingBadge.vue'
|
||||
import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import {
|
||||
isCustomNode as isCustomNodeDef,
|
||||
NodeSourceType
|
||||
} from '@/types/nodeSource'
|
||||
import { getProviderIcon, getProviderName } from '@/utils/categoryUtil'
|
||||
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
nodeDef,
|
||||
@@ -105,6 +139,9 @@ const {
|
||||
hideBookmarkIcon?: boolean
|
||||
}>()
|
||||
|
||||
const badgePillClass =
|
||||
'flex h-[18px] max-w-28 shrink-0 items-center justify-center gap-1 rounded-full bg-secondary-background-selected px-2'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const showCategory = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
|
||||
@@ -122,6 +159,8 @@ const nodeFrequency = computed(() =>
|
||||
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const isBookmarked = computed(() => nodeBookmarkStore.isBookmarked(nodeDef))
|
||||
const providerName = computed(() => getProviderName(nodeDef.category))
|
||||
const isCustomNode = computed(() => isCustomNodeDef(nodeDef))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
166
src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue
Normal file
166
src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<PopoverRoot v-model:open="open" @update:open="onOpenChange">
|
||||
<PopoverTrigger as-child>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
:side-offset="4"
|
||||
:collision-padding="10"
|
||||
class="z-[1001] w-64 rounded-lg border border-border-default bg-base-background px-4 py-1 shadow-interface will-change-[transform,opacity] data-[state=open]:data-[side=bottom]:animate-slideUpAndFade"
|
||||
@open-auto-focus="onOpenAutoFocus"
|
||||
@close-auto-focus="onCloseAutoFocus"
|
||||
@escape-key-down.prevent
|
||||
@keydown.escape.stop="closeWithEscape"
|
||||
>
|
||||
<ListboxRoot
|
||||
multiple
|
||||
selection-behavior="toggle"
|
||||
:model-value="selectedValues"
|
||||
@update:model-value="onSelectionChange"
|
||||
>
|
||||
<div
|
||||
class="mt-2 flex h-8 items-center gap-2 rounded border border-border-default px-2"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<ListboxFilter
|
||||
ref="searchFilterRef"
|
||||
v-model="searchQuery"
|
||||
:placeholder="t('g.search')"
|
||||
class="h-full w-full border-none bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ t('g.itemsSelected', { selectedCount: selectedValues.length }) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="selectedValues.length > 0"
|
||||
type="button"
|
||||
class="cursor-pointer border-none bg-transparent text-sm text-base-foreground"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
{{ t('g.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-border-default" />
|
||||
|
||||
<ListboxContent class="max-h-64 overflow-y-auto py-3">
|
||||
<ListboxItem
|
||||
v-for="option in filteredOptions"
|
||||
:key="option"
|
||||
:value="option"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-1 py-2 text-sm text-foreground outline-none data-[highlighted]:bg-secondary-background-hover"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded border border-border-default',
|
||||
selectedValues.includes(option) &&
|
||||
'border-primary bg-primary text-primary-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ListboxItemIndicator>
|
||||
<i class="icon-[lucide--check] size-3" />
|
||||
</ListboxItemIndicator>
|
||||
</span>
|
||||
<span class="truncate">{{ option }}</span>
|
||||
<span
|
||||
class="mr-1 text-lg leading-none ml-auto"
|
||||
:style="{ color: getLinkTypeColor(option) }"
|
||||
>
|
||||
•
|
||||
</span>
|
||||
</ListboxItem>
|
||||
<div
|
||||
v-if="filteredOptions.length === 0"
|
||||
class="px-1 py-4 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('g.noResults') }}
|
||||
</div>
|
||||
</ListboxContent>
|
||||
</ListboxRoot>
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AcceptableValue } from 'reka-ui'
|
||||
import {
|
||||
ListboxContent,
|
||||
ListboxFilter,
|
||||
ListboxItem,
|
||||
ListboxItemIndicator,
|
||||
ListboxRoot,
|
||||
PopoverContent,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
|
||||
import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
|
||||
import { getLinkTypeColor } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { chip, selectedValues } = defineProps<{
|
||||
chip: FilterChip
|
||||
selectedValues: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [value: string]
|
||||
clear: []
|
||||
escapeClose: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const open = ref(false)
|
||||
const closedWithEscape = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const searchFilterRef = ref<InstanceType<typeof ListboxFilter>>()
|
||||
|
||||
function onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) searchQuery.value = ''
|
||||
}
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const { fuseSearch } = chip.filter
|
||||
if (searchQuery.value) {
|
||||
return fuseSearch.search(searchQuery.value).slice(0, 64)
|
||||
}
|
||||
return fuseSearch.data.slice().sort()
|
||||
})
|
||||
|
||||
function closeWithEscape() {
|
||||
closedWithEscape.value = true
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onOpenAutoFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
const el = searchFilterRef.value?.$el as HTMLInputElement | undefined
|
||||
el?.focus()
|
||||
}
|
||||
|
||||
function onCloseAutoFocus(event: Event) {
|
||||
if (closedWithEscape.value) {
|
||||
event.preventDefault()
|
||||
closedWithEscape.value = false
|
||||
emit('escapeClose')
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectionChange(value: AcceptableValue) {
|
||||
const newValues = value as string[]
|
||||
const added = newValues.find((v) => !selectedValues.includes(v))
|
||||
const removed = selectedValues.find((v) => !newValues.includes(v))
|
||||
const toggled = added ?? removed
|
||||
if (toggled) emit('toggle', toggled)
|
||||
}
|
||||
</script>
|
||||
@@ -41,11 +41,13 @@ export const testI18n = createI18n({
|
||||
essentials: 'Essentials',
|
||||
custom: 'Custom',
|
||||
noResults: 'No results',
|
||||
filterByType: 'Filter by {type}...',
|
||||
input: 'Input',
|
||||
output: 'Output',
|
||||
source: 'Source',
|
||||
search: 'Search'
|
||||
search: 'Search',
|
||||
blueprints: 'Blueprints',
|
||||
partnerNodes: 'Partner Nodes',
|
||||
extensions: 'Extensions'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,10 +179,11 @@
|
||||
"nodesCount": "{count} nodes | {count} node | {count} nodes",
|
||||
"addNode": "Add a node...",
|
||||
"filterBy": "Filter by:",
|
||||
"filterByType": "Filter by {type}...",
|
||||
"mostRelevant": "Most relevant",
|
||||
"favorites": "Favorites",
|
||||
"essentials": "Essentials",
|
||||
"blueprints": "Blueprints",
|
||||
"partnerNodes": "Partner Nodes",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"community": "Community",
|
||||
|
||||
@@ -55,25 +55,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="badge in priceBadges ?? []" :key="badge.required">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex h-5 bg-component-node-widget-background p-1 items-center text-xs shrink-0',
|
||||
badge.rest ? 'rounded-l-full pr-1' : 'rounded-full'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="h-full icon-[lucide--component] bg-amber-400" />
|
||||
<span class="truncate" v-text="badge.required" />
|
||||
</span>
|
||||
<span
|
||||
v-if="badge.rest"
|
||||
class="truncate -ml-2.5 grow-1 basis-0 bg-component-node-widget-background rounded-r-full max-w-max min-w-0"
|
||||
>
|
||||
<span class="pr-2" v-text="badge.rest" />
|
||||
</span>
|
||||
</template>
|
||||
<CreditBadge
|
||||
v-for="badge in priceBadges ?? []"
|
||||
:key="badge.required"
|
||||
:text="badge.required"
|
||||
:rest="badge.rest"
|
||||
/>
|
||||
<NodeBadge v-if="statusBadge" v-bind="statusBadge" />
|
||||
<i
|
||||
v-if="isPinned"
|
||||
@@ -88,6 +75,7 @@
|
||||
import { computed, onErrorCaptured, ref, watch } from 'vue'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import CreditBadge from '@/components/node/CreditBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
|
||||
import {
|
||||
NodeSourceType,
|
||||
getNodeSource,
|
||||
isCustomNode,
|
||||
isEssentialNode
|
||||
} from '@/types/nodeSource'
|
||||
import type { NodeSource } from '@/types/nodeSource'
|
||||
|
||||
describe('getNodeSource', () => {
|
||||
it('should return UNKNOWN_NODE_SOURCE when python_module is undefined', () => {
|
||||
@@ -119,3 +125,61 @@ describe('getNodeSource', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function makeNode(
|
||||
type: NodeSourceType,
|
||||
python_module?: string
|
||||
): { nodeSource: NodeSource; python_module?: string } {
|
||||
return {
|
||||
nodeSource: {
|
||||
type,
|
||||
className: '',
|
||||
displayText: '',
|
||||
badgeText: ''
|
||||
},
|
||||
python_module
|
||||
}
|
||||
}
|
||||
|
||||
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, 'custom_nodes.foo'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for Core nodes', () => {
|
||||
expect(isCustomNode(makeNode(NodeSourceType.Core, 'nodes.foo'))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for Essentials nodes', () => {
|
||||
expect(
|
||||
isCustomNode(makeNode(NodeSourceType.Essentials, 'custom_nodes.foo'))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for Unknown nodes', () => {
|
||||
expect(isCustomNode(makeNode(NodeSourceType.Unknown))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for Blueprint nodes', () => {
|
||||
expect(isCustomNode(makeNode(NodeSourceType.Blueprint, 'blueprint'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,6 +129,24 @@ export const getNodeSource = (
|
||||
}
|
||||
}
|
||||
|
||||
interface NodeDefLike {
|
||||
nodeSource: NodeSource
|
||||
python_module?: string
|
||||
}
|
||||
|
||||
export function isEssentialNode(node: NodeDefLike): boolean {
|
||||
return node.nodeSource.type === NodeSourceType.Essentials
|
||||
}
|
||||
|
||||
export function isCustomNode(node: NodeDefLike): boolean {
|
||||
return (
|
||||
node.nodeSource.type !== NodeSourceType.Core &&
|
||||
node.nodeSource.type !== NodeSourceType.Unknown &&
|
||||
!isEssentialNode(node) &&
|
||||
node.python_module !== 'blueprint'
|
||||
)
|
||||
}
|
||||
|
||||
export enum NodeBadgeMode {
|
||||
None = 'None',
|
||||
ShowAll = 'Show all',
|
||||
|
||||
Reference in New Issue
Block a user