mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-15 20:21:04 +00:00
Compare commits
41 Commits
test-cover
...
pysssss/no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4059303d4 | ||
|
|
5796c6d464 | ||
|
|
dce8b87759 | ||
|
|
833a2f5161 | ||
|
|
4f97a9028c | ||
|
|
3786a46ef7 | ||
|
|
5c3de0081b | ||
|
|
af77920f8f | ||
|
|
c00e285768 | ||
|
|
8f41bc7527 | ||
|
|
11b62c48e3 | ||
|
|
cc3d3f1d25 | ||
|
|
92e65aaaa7 | ||
|
|
f82f8624e1 | ||
|
|
c46316d248 | ||
|
|
8e5dc15e5d | ||
|
|
da2fedebcf | ||
|
|
2a531ff80b | ||
|
|
b6234b96af | ||
|
|
bd66617d3f | ||
|
|
98eac41f07 | ||
|
|
307a1c77c0 | ||
|
|
bbd1e60f7b | ||
|
|
9100058fc1 | ||
|
|
04c00aadd8 | ||
|
|
2f1615c505 | ||
|
|
cf4dfceaee | ||
|
|
dbb70323bf | ||
|
|
6689510591 | ||
|
|
82e62694a9 | ||
|
|
d49f263536 | ||
|
|
d30bb01b4b | ||
|
|
320cd82f0d | ||
|
|
8a30211bea | ||
|
|
12fd0981a8 | ||
|
|
0772f2a7fe | ||
|
|
08666d8e81 | ||
|
|
d18243e085 | ||
|
|
3cba424e52 | ||
|
|
0f3b2e0455 | ||
|
|
fd31f9d0ed |
@@ -5,12 +5,14 @@ import type { ComfyPage } from '@e2e/fixtures/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')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
GraphAddOptions,
|
||||
LGraph,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
@@ -36,6 +40,45 @@ export class NodeOperationsHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async getSelectedNodeIds(): Promise<NodeId[]> {
|
||||
return await this.page.evaluate(() => {
|
||||
const selected = window.app?.canvas?.selected_nodes
|
||||
if (!selected) return []
|
||||
return Object.keys(selected).map(Number)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a node to the graph by type.
|
||||
* @param type - The node type (e.g. 'KSampler', 'VAEDecode')
|
||||
* @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is
|
||||
* true and cursorPosition is provided, a synthetic MouseEvent is created
|
||||
* as the dragEvent.
|
||||
* @param cursorPosition - Client coordinates for ghost placement dragEvent
|
||||
*/
|
||||
async addNode(
|
||||
type: string,
|
||||
options?: Omit<GraphAddOptions, 'dragEvent'>,
|
||||
cursorPosition?: Position
|
||||
): Promise<NodeReference> {
|
||||
const id = await this.page.evaluate(
|
||||
([nodeType, opts, cursor]) => {
|
||||
const node = window.LiteGraph!.createNode(nodeType)!
|
||||
const addOpts: Record<string, unknown> = { ...opts }
|
||||
if (opts?.ghost && cursor) {
|
||||
addOpts.dragEvent = new MouseEvent('click', {
|
||||
clientX: cursor.x,
|
||||
clientY: cursor.y
|
||||
})
|
||||
}
|
||||
window.app!.graph.add(node, addOpts as GraphAddOptions)
|
||||
return node.id
|
||||
},
|
||||
[type, options ?? {}, cursorPosition ?? null] as const
|
||||
)
|
||||
return new NodeReference(id, this.comfyPage)
|
||||
}
|
||||
|
||||
/** Remove all nodes from the graph and clean. */
|
||||
async clearGraph() {
|
||||
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
|
||||
@@ -23,18 +23,14 @@ async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeId = await comfyPage.page.evaluate(
|
||||
([clientX, clientY]) => {
|
||||
const node = window.LiteGraph!.createNode('VAEDecode')!
|
||||
const event = new MouseEvent('click', { clientX, clientY })
|
||||
window.app!.graph.add(node, { ghost: true, dragEvent: event })
|
||||
return node.id
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
const nodeRef = await comfyPage.nodeOps.addNode(
|
||||
'VAEDecode',
|
||||
{ ghost: true },
|
||||
{ x: centerX, y: centerY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return { nodeId, centerX, centerY }
|
||||
return { nodeId: nodeRef.id, centerX, centerY }
|
||||
}
|
||||
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
|
||||
@@ -82,7 +78,6 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(Math.abs(result.diffX)).toBeLessThan(5)
|
||||
expect(Math.abs(result.diffY)).toBeLessThan(5)
|
||||
@@ -158,5 +153,53 @@ for (const mode of ['litegraph', 'vue'] as const) {
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('moving ghost onto existing node and clicking places correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get existing KSampler node from the default workflow
|
||||
const [ksamplerRef] =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
const ksamplerPos = await ksamplerRef.getPosition()
|
||||
const ksamplerSize = await ksamplerRef.getSize()
|
||||
const targetX = Math.round(ksamplerPos.x + ksamplerSize.width / 2)
|
||||
const targetY = Math.round(ksamplerPos.y + ksamplerSize.height / 2)
|
||||
|
||||
// Start ghost placement away from the existing node
|
||||
const startX = 50
|
||||
const startY = 50
|
||||
await comfyPage.page.mouse.move(startX, startY, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const ghostRef = await comfyPage.nodeOps.addNode(
|
||||
'VAEDecode',
|
||||
{ ghost: true },
|
||||
{ x: startX, y: startY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Move ghost onto the existing node
|
||||
await comfyPage.page.mouse.move(targetX, targetY, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Click to finalize — on top of the existing node
|
||||
await comfyPage.page.mouse.click(targetX, targetY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Ghost should be placed (no longer ghost)
|
||||
const ghostResult = await getNodeById(comfyPage, ghostRef.id)
|
||||
expect(ghostResult).not.toBeNull()
|
||||
expect(ghostResult!.ghost).toBe(false)
|
||||
|
||||
// Ghost node should have moved from its start position toward where we clicked
|
||||
const ghostPos = await ghostRef.getPosition()
|
||||
expect(
|
||||
Math.abs(ghostPos.x - startX) > 20 || Math.abs(ghostPos.y - startY) > 20
|
||||
).toBe(true)
|
||||
|
||||
// Existing node should NOT be selected
|
||||
const selectedIds = await comfyPage.nodeOps.getSelectedNodeIds()
|
||||
expect(selectedIds).not.toContain(ksamplerRef.id)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,7 +56,9 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
})
|
||||
|
||||
test.describe('Category navigation', () => {
|
||||
test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
|
||||
test('Bookmarked filter shows only bookmarked nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
|
||||
'KSampler'
|
||||
@@ -66,7 +68,7 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
await comfyPage.canvasOps.doubleClick()
|
||||
await expect(searchBoxV2.input).toBeVisible()
|
||||
|
||||
await searchBoxV2.categoryButton('favorites').click()
|
||||
await searchBoxV2.filterBarButton('Bookmarked').click()
|
||||
|
||||
await expect(searchBoxV2.results).toHaveCount(1)
|
||||
await expect(searchBoxV2.results.first()).toContainText('KSampler')
|
||||
@@ -101,7 +103,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()
|
||||
|
||||
@@ -97,7 +97,7 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
|
||||
// Apply Input filter with MODEL type
|
||||
await searchBoxV2.filterBarButton('Input').click()
|
||||
await expect(searchBoxV2.filterOptions.first()).toBeVisible()
|
||||
await searchBoxV2.input.fill('MODEL')
|
||||
await searchBoxV2.filterSearch.fill('MODEL')
|
||||
await searchBoxV2.filterOptions
|
||||
.filter({ hasText: 'MODEL' })
|
||||
.first()
|
||||
|
||||
@@ -589,8 +589,6 @@
|
||||
background-color: color-mix(in srgb, currentColor 20%, transparent);
|
||||
font-weight: 700;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0 0.125rem;
|
||||
margin: -0.125rem 0.125rem;
|
||||
}
|
||||
|
||||
@utility scrollbar-hide {
|
||||
|
||||
@@ -201,6 +201,13 @@ describe('formatUtil', () => {
|
||||
'<span class="highlight">foo</span> bar <span class="highlight">foo</span>'
|
||||
)
|
||||
})
|
||||
|
||||
it('should highlight cross-word matches', () => {
|
||||
const result = highlightQuery('convert image to mask', 'geto', false)
|
||||
expect(result).toBe(
|
||||
'convert ima<span class="highlight">ge to</span> mask'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilenameDetails', () => {
|
||||
|
||||
@@ -74,10 +74,14 @@ export function highlightQuery(
|
||||
text = DOMPurify.sanitize(text)
|
||||
}
|
||||
|
||||
// Escape special regex characters in the query string
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
// Escape special regex characters, then join with optional
|
||||
// whitespace so cross-word matches (e.g. "geto" → "imaGE TO") are
|
||||
// highlighted correctly.
|
||||
const pattern = Array.from(query)
|
||||
.map((ch) => ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('\\s*')
|
||||
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi')
|
||||
const regex = new RegExp(`(${pattern})`, 'gi')
|
||||
return text.replace(regex, '<span class="highlight">$1</span>')
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
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 shrink-0 items-center bg-component-node-widget-background p-1 text-xs',
|
||||
rest ? 'rounded-l-full pr-1' : 'rounded-full'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--component] h-full bg-amber-400" />
|
||||
<span class="truncate" v-text="text" />
|
||||
</span>
|
||||
<span
|
||||
v-if="rest"
|
||||
class="-ml-2.5 max-w-max min-w-0 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>
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-50 flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
|
||||
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
|
||||
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
|
||||
>
|
||||
<div ref="previewContainerRef" class="overflow-hidden p-3">
|
||||
<div ref="previewWrapperRef" class="origin-top-left scale-50">
|
||||
<div
|
||||
ref="previewWrapperRef"
|
||||
class="origin-top-left"
|
||||
:style="{ transform: `scale(${scaleFactor})` }"
|
||||
>
|
||||
<LGraphNodePreview :node-def="nodeDef" position="relative" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,21 +23,21 @@
|
||||
<!-- Category Path -->
|
||||
<p
|
||||
v-if="showCategoryPath && nodeDef.category"
|
||||
class="-mt-1 text-xs text-muted-foreground"
|
||||
class="-mt-1 truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ nodeDef.category.replaceAll('/', ' > ') }}
|
||||
{{ categoryPath }}
|
||||
</p>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex flex-wrap gap-2 empty:hidden">
|
||||
<NodePricingBadge :node-def="nodeDef" />
|
||||
<div class="flex flex-wrap gap-2 overflow-hidden empty:hidden">
|
||||
<NodePricingBadge class="max-w-full truncate" :node-def="nodeDef" />
|
||||
<NodeProviderBadge :node-def="nodeDef" />
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
v-if="nodeDef.description"
|
||||
class="m-0 text-2xs/normal font-normal text-muted-foreground"
|
||||
class="m-0 max-h-[30vh] overflow-y-auto text-2xs/normal font-normal text-muted-foreground"
|
||||
>
|
||||
{{ nodeDef.description }}
|
||||
</p>
|
||||
@@ -99,17 +104,20 @@ import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
const SCALE_FACTOR = 0.5
|
||||
const BASE_WIDTH_PX = 200
|
||||
const BASE_SCALE = 0.5
|
||||
const PREVIEW_CONTAINER_PADDING_PX = 24
|
||||
|
||||
const {
|
||||
nodeDef,
|
||||
showInputsAndOutputs = true,
|
||||
showCategoryPath = false
|
||||
showCategoryPath = false,
|
||||
scaleFactor = 0.5
|
||||
} = defineProps<{
|
||||
nodeDef: ComfyNodeDefImpl
|
||||
showInputsAndOutputs?: boolean
|
||||
showCategoryPath?: boolean
|
||||
scaleFactor?: number
|
||||
}>()
|
||||
|
||||
const previewContainerRef = ref<HTMLElement>()
|
||||
@@ -118,11 +126,13 @@ const previewWrapperRef = ref<HTMLElement>()
|
||||
useResizeObserver(previewWrapperRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry && previewContainerRef.value) {
|
||||
const scaledHeight = entry.contentRect.height * SCALE_FACTOR
|
||||
const scaledHeight = entry.contentRect.height * scaleFactor
|
||||
previewContainerRef.value.style.height = `${scaledHeight + PREVIEW_CONTAINER_PADDING_PX}px`
|
||||
}
|
||||
})
|
||||
|
||||
const categoryPath = computed(() => nodeDef.category?.replaceAll('/', ' / '))
|
||||
|
||||
const inputs = computed(() => {
|
||||
if (!nodeDef.inputs) return []
|
||||
return Object.entries(nodeDef.inputs)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:pt="{
|
||||
root: {
|
||||
class: useSearchBoxV2
|
||||
? 'w-4/5 min-w-[32rem] max-w-[56rem] border-0 bg-transparent mt-[10vh] max-md:w-[95%] max-md:min-w-0 overflow-visible'
|
||||
? 'w-full max-w-[56rem] min-w-[32rem] max-md:min-w-0 bg-transparent border-0 overflow-visible'
|
||||
: 'invisible-dialog-root'
|
||||
},
|
||||
mask: {
|
||||
@@ -36,7 +36,9 @@
|
||||
v-if="hoveredNodeDef && enableNodePreview"
|
||||
:key="hoveredNodeDef.name"
|
||||
:node-def="hoveredNodeDef"
|
||||
:scale-factor="0.625"
|
||||
show-category-path
|
||||
inert
|
||||
class="absolute top-0 left-full ml-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
|
||||
import {
|
||||
@@ -11,12 +10,12 @@ import {
|
||||
} from '@/components/searchbox/v2/__test__/testUtils'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => undefined),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}))
|
||||
type SidebarProps = Partial<{
|
||||
selectedCategory: string
|
||||
hidePresets: boolean
|
||||
rootLabel: string
|
||||
rootKey: string
|
||||
}>
|
||||
|
||||
describe('NodeSearchCategorySidebar', () => {
|
||||
beforeEach(() => {
|
||||
@@ -24,35 +23,30 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
setupTestPinia()
|
||||
})
|
||||
|
||||
async function createRender(props = {}) {
|
||||
function createRender(props: SidebarProps = {}) {
|
||||
const user = userEvent.setup()
|
||||
const onUpdateSelectedCategory = vi.fn()
|
||||
const baseProps = { selectedCategory: 'most-relevant', ...props }
|
||||
|
||||
let currentProps = { ...baseProps }
|
||||
let rerenderFn: (
|
||||
p: typeof baseProps & Record<string, unknown>
|
||||
) => void = () => {}
|
||||
|
||||
function makeProps(overrides = {}) {
|
||||
const merged = { ...currentProps, ...overrides }
|
||||
return {
|
||||
...merged,
|
||||
'onUpdate:selectedCategory': (val: string) => {
|
||||
onUpdateSelectedCategory(val)
|
||||
currentProps = { ...currentProps, selectedCategory: val }
|
||||
rerenderFn(makeProps())
|
||||
}
|
||||
}
|
||||
const onUpdateSelectedCategory = vi.fn<(value: string) => void>()
|
||||
const initialProps: SidebarProps & { selectedCategory: string } = {
|
||||
selectedCategory: 'most-relevant',
|
||||
...props
|
||||
}
|
||||
|
||||
const result = render(NodeSearchCategorySidebar, {
|
||||
props: makeProps(),
|
||||
props: {
|
||||
...initialProps,
|
||||
'onUpdate:selectedCategory': onUpdateSelectedCategory
|
||||
},
|
||||
global: { plugins: [testI18n] }
|
||||
})
|
||||
rerenderFn = (p) => result.rerender(p)
|
||||
await nextTick()
|
||||
return { user, onUpdateSelectedCategory }
|
||||
|
||||
const rerender = (overrides: SidebarProps) =>
|
||||
result.rerender({
|
||||
...initialProps,
|
||||
...overrides,
|
||||
'onUpdate:selectedCategory': onUpdateSelectedCategory
|
||||
})
|
||||
|
||||
return { user, onUpdateSelectedCategory, rerender }
|
||||
}
|
||||
|
||||
async function clickCategory(
|
||||
@@ -60,40 +54,26 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
text: string,
|
||||
exact = false
|
||||
) {
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const btn = buttons.find((b) =>
|
||||
const candidates = [
|
||||
...screen.queryAllByRole('button'),
|
||||
...screen.queryAllByRole('treeitem')
|
||||
]
|
||||
const btn = candidates.find((b) =>
|
||||
exact ? b.textContent?.trim() === text : b.textContent?.includes(text)
|
||||
)
|
||||
expect(btn, `Expected to find a button with text "${text}"`).toBeDefined()
|
||||
await user.click(btn!)
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('preset categories', () => {
|
||||
it('should render all preset categories', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'EssentialNode',
|
||||
essentials_category: 'basic',
|
||||
python_module: 'comfy_essentials'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
await createRender()
|
||||
it('should render Most relevant preset category', () => {
|
||||
createRender()
|
||||
|
||||
expect(screen.getByText('Most relevant')).toBeInTheDocument()
|
||||
expect(screen.getByText('Recents')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.getByText('Essentials')).toBeInTheDocument()
|
||||
expect(screen.getByText('Blueprints')).toBeInTheDocument()
|
||||
expect(screen.getByText('Partner')).toBeInTheDocument()
|
||||
expect(screen.getByText('Comfy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extensions')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should mark the selected preset category as selected', async () => {
|
||||
await createRender({ selectedCategory: 'most-relevant' })
|
||||
it('should mark the selected preset category as selected', () => {
|
||||
createRender({ selectedCategory: 'most-relevant' })
|
||||
|
||||
expect(screen.getByTestId('category-most-relevant')).toHaveAttribute(
|
||||
'aria-current',
|
||||
@@ -102,26 +82,30 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
})
|
||||
|
||||
it('should emit update:selectedCategory when preset is clicked', async () => {
|
||||
const { user, onUpdateSelectedCategory } = await createRender({
|
||||
const { user, onUpdateSelectedCategory } = createRender({
|
||||
selectedCategory: 'most-relevant'
|
||||
})
|
||||
|
||||
await clickCategory(user, 'Favorites')
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' })
|
||||
])
|
||||
|
||||
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('favorites')
|
||||
await screen.findByText('sampling')
|
||||
await clickCategory(user, 'sampling')
|
||||
|
||||
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
|
||||
})
|
||||
})
|
||||
|
||||
describe('category tree', () => {
|
||||
it('should render top-level categories from node definitions', async () => {
|
||||
it('should render top-level categories from node definitions', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'loaders' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'conditioning' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
await createRender()
|
||||
createRender()
|
||||
|
||||
expect(screen.getByText('sampling')).toBeInTheDocument()
|
||||
expect(screen.getByText('loaders')).toBeInTheDocument()
|
||||
@@ -132,9 +116,8 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user, onUpdateSelectedCategory } = await createRender()
|
||||
const { user, onUpdateSelectedCategory } = createRender()
|
||||
|
||||
await clickCategory(user, 'sampling')
|
||||
|
||||
@@ -147,20 +130,18 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'sampling/basic' })
|
||||
createMockNodeDef({ name: 'Node3', category: 'sampling/basic' }),
|
||||
createMockNodeDef({ name: 'Node4', category: 'loaders' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user } = await createRender()
|
||||
const { user } = createRender()
|
||||
|
||||
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
|
||||
|
||||
await clickCategory(user, 'sampling')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('basic')).toBeInTheDocument()
|
||||
})
|
||||
await screen.findByText('advanced')
|
||||
expect(screen.getByText('basic')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should collapse sibling category when another is expanded', async () => {
|
||||
@@ -170,15 +151,12 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
createMockNodeDef({ name: 'Node3', category: 'image' }),
|
||||
createMockNodeDef({ name: 'Node4', category: 'image/upscale' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user } = await createRender()
|
||||
const { user } = createRender()
|
||||
|
||||
// Expand sampling
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('advanced')).toBeInTheDocument()
|
||||
})
|
||||
await screen.findByText('advanced')
|
||||
|
||||
// Expand image — sampling should collapse
|
||||
await clickCategory(user, 'image', true)
|
||||
@@ -192,17 +170,15 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
it('should emit update:selectedCategory when subcategory is clicked', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user, onUpdateSelectedCategory } = await createRender()
|
||||
const { user, onUpdateSelectedCategory } = createRender()
|
||||
|
||||
// Expand sampling category
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('advanced')).toBeInTheDocument()
|
||||
})
|
||||
await screen.findByText('advanced')
|
||||
|
||||
// Click on advanced subcategory
|
||||
await clickCategory(user, 'advanced')
|
||||
@@ -213,13 +189,12 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
})
|
||||
|
||||
describe('category selection highlighting', () => {
|
||||
it('should mark selected top-level category as selected', async () => {
|
||||
it('should mark selected top-level category as selected', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
await createRender({ selectedCategory: 'sampling' })
|
||||
createRender({ selectedCategory: 'sampling' })
|
||||
|
||||
expect(screen.getByTestId('category-sampling')).toHaveAttribute(
|
||||
'aria-current',
|
||||
@@ -230,19 +205,17 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
it('should emit selected subcategory when expanded', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' })
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user, onUpdateSelectedCategory } = await createRender({
|
||||
const { user, onUpdateSelectedCategory } = createRender({
|
||||
selectedCategory: 'most-relevant'
|
||||
})
|
||||
|
||||
// Expand and click subcategory
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('advanced')).toBeInTheDocument()
|
||||
})
|
||||
await screen.findByText('advanced')
|
||||
await clickCategory(user, 'advanced')
|
||||
|
||||
const calls = onUpdateSelectedCategory.mock.calls
|
||||
@@ -250,15 +223,121 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should support deeply nested categories (3+ levels)', async () => {
|
||||
describe('hidePresets prop', () => {
|
||||
it('should hide preset categories when hidePresets is true', () => {
|
||||
createRender({ hidePresets: true })
|
||||
|
||||
expect(screen.queryByText('Most relevant')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit category without root/ prefix', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' })
|
||||
])
|
||||
|
||||
const { user, onUpdateSelectedCategory } = createRender()
|
||||
|
||||
await clickCategory(user, 'sampling')
|
||||
|
||||
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
|
||||
})
|
||||
|
||||
describe('rootLabel wrapping', () => {
|
||||
it('should wrap multiple top-level categories under rootLabel key', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'N1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'N2', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { user, onUpdateSelectedCategory } = createRender({
|
||||
rootLabel: 'Extensions',
|
||||
rootKey: 'custom'
|
||||
})
|
||||
|
||||
expect(screen.getByText('Extensions')).toBeInTheDocument()
|
||||
|
||||
// Expand the wrapper root
|
||||
const customBtn = screen.getByTestId('category-custom')
|
||||
expect(customBtn).toBeInTheDocument()
|
||||
await user.click(customBtn)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('sampling')).toBeInTheDocument()
|
||||
expect(screen.getByText('loaders')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Subcategories should be prefixed with the root key
|
||||
expect(screen.getByTestId('category-custom/sampling')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId('category-custom/sampling'))
|
||||
const calls = onUpdateSelectedCategory.mock.calls
|
||||
expect(calls[calls.length - 1]).toEqual(['custom/sampling'])
|
||||
})
|
||||
|
||||
it('should derive root key from rootLabel when rootKey is not provided', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'N1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'N2', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { user, onUpdateSelectedCategory } = createRender({
|
||||
rootLabel: 'Custom'
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('category-custom'))
|
||||
await user.click(await screen.findByTestId('category-custom/sampling'))
|
||||
|
||||
const calls = onUpdateSelectedCategory.mock.calls
|
||||
expect(calls[calls.length - 1]).toEqual(['custom/sampling'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('external selectedCategory updates', () => {
|
||||
it('should update expanded state when selectedCategory changes externally', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { rerender } = createRender({
|
||||
selectedCategory: 'most-relevant'
|
||||
})
|
||||
|
||||
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
|
||||
|
||||
await rerender({ selectedCategory: 'sampling' })
|
||||
|
||||
await screen.findByText('advanced')
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit autoExpand when there is a single root category', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'api' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'api/image' })
|
||||
])
|
||||
|
||||
const onAutoExpand = vi.fn()
|
||||
render(NodeSearchCategorySidebar, {
|
||||
props: {
|
||||
selectedCategory: 'most-relevant',
|
||||
onAutoExpand: onAutoExpand
|
||||
},
|
||||
global: { plugins: [testI18n] }
|
||||
})
|
||||
|
||||
expect(onAutoExpand).toHaveBeenCalledWith('api')
|
||||
})
|
||||
|
||||
it('should support deeply nested categories', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'api' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'api/image' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'api/image/BFL' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user, onUpdateSelectedCategory } = await createRender()
|
||||
const { user, onUpdateSelectedCategory } = createRender()
|
||||
|
||||
// Only top-level visible initially
|
||||
expect(screen.getByText('api')).toBeInTheDocument()
|
||||
@@ -267,16 +346,12 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
|
||||
// Expand api
|
||||
await clickCategory(user, 'api', true)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('image')).toBeInTheDocument()
|
||||
})
|
||||
await screen.findByText('image')
|
||||
expect(screen.queryByText('BFL')).not.toBeInTheDocument()
|
||||
|
||||
// Expand image
|
||||
await clickCategory(user, 'image', true)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('BFL')).toBeInTheDocument()
|
||||
})
|
||||
await screen.findByText('BFL')
|
||||
|
||||
// Click BFL and verify emission
|
||||
await clickCategory(user, 'BFL', true)
|
||||
@@ -285,16 +360,179 @@ describe('NodeSearchCategorySidebar', () => {
|
||||
expect(calls[calls.length - 1]).toEqual(['api/image/BFL'])
|
||||
})
|
||||
|
||||
it('should emit category without root/ prefix', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' })
|
||||
])
|
||||
await nextTick()
|
||||
describe('keyboard navigation', () => {
|
||||
it('should expand a collapsed tree node on ArrowRight', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { user, onUpdateSelectedCategory } = await createRender()
|
||||
const { user, onUpdateSelectedCategory } = createRender()
|
||||
|
||||
await clickCategory(user, 'sampling')
|
||||
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
|
||||
|
||||
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
|
||||
const samplingBtn = screen.getByTestId('category-sampling')
|
||||
samplingBtn.focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
// Should have emitted select for sampling, expanding it
|
||||
expect(onUpdateSelectedCategory).toHaveBeenCalledWith('sampling')
|
||||
})
|
||||
|
||||
it('should collapse an expanded tree node on ArrowLeft', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { user } = createRender()
|
||||
|
||||
// First expand sampling by clicking
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await screen.findByText('advanced')
|
||||
|
||||
const samplingBtn = screen.getByTestId('category-sampling')
|
||||
samplingBtn.focus()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
// Collapse toggles internal state; children should be hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('advanced')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should focus first child on ArrowRight when already expanded', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { user } = createRender()
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await screen.findByText('advanced')
|
||||
|
||||
const samplingBtn = screen.getByTestId('category-sampling')
|
||||
samplingBtn.focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('category-sampling/advanced')).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
it('should focus parent on ArrowLeft from a leaf or collapsed node', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { user } = createRender()
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await screen.findByText('advanced')
|
||||
|
||||
screen.getByTestId('category-sampling/advanced').focus()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('category-sampling')).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
it('should collapse sampling on ArrowLeft, not just its expanded child', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({
|
||||
name: 'Node2',
|
||||
category: 'sampling/custom_sampling'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'Node3',
|
||||
category: 'sampling/custom_sampling/child'
|
||||
}),
|
||||
createMockNodeDef({ name: 'Node4', category: 'loaders' })
|
||||
])
|
||||
|
||||
const { user } = createRender()
|
||||
|
||||
// Step 1: Expand sampling
|
||||
await clickCategory(user, 'sampling', true)
|
||||
await screen.findByText('custom_sampling')
|
||||
|
||||
// Step 2: Expand custom_sampling
|
||||
await clickCategory(user, 'custom_sampling', true)
|
||||
await screen.findByText('child')
|
||||
|
||||
// Step 3: Navigate back to sampling (keyboard focus only)
|
||||
const samplingBtn = screen.getByTestId('category-sampling')
|
||||
samplingBtn.focus()
|
||||
|
||||
// Step 4: Press left on sampling
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
// Sampling should collapse entirely — custom_sampling should not be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('custom_sampling')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should collapse 4-deep tree to parent of level 2 on ArrowLeft', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'N1', category: 'a' }),
|
||||
createMockNodeDef({ name: 'N2', category: 'a/b' }),
|
||||
createMockNodeDef({ name: 'N3', category: 'a/b/c' }),
|
||||
createMockNodeDef({ name: 'N4', category: 'a/b/c/d' }),
|
||||
createMockNodeDef({ name: 'N5', category: 'other' })
|
||||
])
|
||||
|
||||
const { user } = createRender()
|
||||
|
||||
// Expand a → a/b → a/b/c
|
||||
await clickCategory(user, 'a', true)
|
||||
await screen.findByText('b')
|
||||
|
||||
await clickCategory(user, 'b', true)
|
||||
await screen.findByText('c')
|
||||
|
||||
await clickCategory(user, 'c', true)
|
||||
await screen.findByText('d')
|
||||
|
||||
// Focus level 2 (a/b) and press ArrowLeft
|
||||
const bBtn = screen.getByTestId('category-a/b')
|
||||
bBtn.focus()
|
||||
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
// Level 2 and below should collapse, but level 1 (a) stays expanded
|
||||
// so 'b' is still visible but 'c' and 'd' are not
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('c')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('b')).toBeInTheDocument()
|
||||
expect(screen.queryByText('d')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set aria-expanded on tree nodes with children', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
|
||||
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
|
||||
createMockNodeDef({ name: 'Node3', category: 'loaders' })
|
||||
])
|
||||
|
||||
createRender()
|
||||
|
||||
expect(screen.getByTestId('category-sampling')).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
|
||||
// Leaf node should not have aria-expanded
|
||||
expect(screen.getByTestId('category-loaders')).not.toHaveAttribute(
|
||||
'aria-expanded'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,105 +1,109 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col overflow-y-auto py-2.5">
|
||||
<RovingFocusGroup
|
||||
as="div"
|
||||
orientation="vertical"
|
||||
:loop="true"
|
||||
class="group/categories flex min-h-0 flex-col overflow-y-auto py-2.5 select-none"
|
||||
>
|
||||
<!-- Preset categories -->
|
||||
<div class="flex flex-col px-1">
|
||||
<button
|
||||
<div v-if="!hidePresets" class="flex flex-col px-3">
|
||||
<RovingFocusItem
|
||||
v-for="preset in topCategories"
|
||||
:key="preset.id"
|
||||
type="button"
|
||||
:data-testid="`category-${preset.id}`"
|
||||
:aria-current="selectedCategory === preset.id || undefined"
|
||||
:class="categoryBtnClass(preset.id)"
|
||||
@click="selectCategory(preset.id)"
|
||||
as-child
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Source categories -->
|
||||
<div class="my-2 flex flex-col border-y border-border-subtle px-1 py-2">
|
||||
<button
|
||||
v-for="preset in sourceCategories"
|
||||
:key="preset.id"
|
||||
type="button"
|
||||
:data-testid="`category-${preset.id}`"
|
||||
:aria-current="selectedCategory === preset.id || undefined"
|
||||
:class="categoryBtnClass(preset.id)"
|
||||
@click="selectCategory(preset.id)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
:data-testid="`category-${preset.id}`"
|
||||
:aria-current="selectedCategory === preset.id || undefined"
|
||||
:class="categoryBtnClass(preset.id)"
|
||||
@click="selectCategory(preset.id)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</Button>
|
||||
</RovingFocusItem>
|
||||
</div>
|
||||
|
||||
<!-- Category tree -->
|
||||
<div class="flex flex-col px-1">
|
||||
<div
|
||||
role="tree"
|
||||
:aria-label="t('g.category')"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col px-3',
|
||||
!hidePresets && '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"
|
||||
:expanded-category="expandedCategory"
|
||||
:hide-chevrons="hideChevrons"
|
||||
@select="selectCategory"
|
||||
@collapse="collapseCategory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RovingFocusGroup>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export const DEFAULT_CATEGORY = 'most-relevant'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RovingFocusGroup, RovingFocusItem } from 'reka-ui'
|
||||
|
||||
import NodeSearchCategoryTreeNode, {
|
||||
CATEGORY_SELECTED_CLASS,
|
||||
CATEGORY_UNSELECTED_CLASS
|
||||
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
|
||||
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
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
autoExpand: [key: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const topCategories = computed(() => [
|
||||
{ id: 'most-relevant', label: t('g.mostRelevant') },
|
||||
{ id: 'recents', label: t('g.recents') },
|
||||
{ id: 'favorites', label: t('g.favorites') }
|
||||
{ id: DEFAULT_CATEGORY, label: t('g.mostRelevant') }
|
||||
])
|
||||
|
||||
const hasEssentialNodes = computed(() =>
|
||||
nodeDefStore.visibleNodeDefs.some(
|
||||
(n) => n.nodeSource.type === NodeSourceType.Essentials
|
||||
)
|
||||
)
|
||||
|
||||
const sourceCategories = computed(() => {
|
||||
const categories = []
|
||||
if (hasEssentialNodes.value) {
|
||||
categories.push({ id: 'essentials', label: t('g.essentials') })
|
||||
}
|
||||
categories.push(
|
||||
{
|
||||
id: 'blueprints',
|
||||
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
|
||||
},
|
||||
{ id: 'partner', label: t('g.partner') },
|
||||
{ id: 'comfy', label: t('g.comfy') },
|
||||
{ id: 'extensions', label: t('g.extensions') }
|
||||
)
|
||||
return categories
|
||||
})
|
||||
|
||||
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\//, '')
|
||||
|
||||
@@ -114,28 +118,84 @@ 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
|
||||
})
|
||||
|
||||
// Notify parent when there is only a single root category to auto-expand
|
||||
watch(
|
||||
categoryTree,
|
||||
(nodes) => {
|
||||
if (nodes.length === 1 && nodes[0].children?.length) {
|
||||
const rootKey = nodes[0].key
|
||||
if (
|
||||
selectedCategory.value !== rootKey &&
|
||||
!selectedCategory.value.startsWith(rootKey + '/')
|
||||
) {
|
||||
emit('autoExpand', rootKey)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function categoryBtnClass(id: string) {
|
||||
return cn(
|
||||
'cursor-pointer rounded-sm border-none bg-transparent px-3 py-2.5 text-left text-sm transition-colors',
|
||||
'h-auto justify-start bg-transparent py-2.5 pr-3 text-sm font-normal',
|
||||
hideChevrons ? 'pl-3' : 'pl-9',
|
||||
selectedCategory.value === id
|
||||
? CATEGORY_SELECTED_CLASS
|
||||
: CATEGORY_UNSELECTED_CLASS
|
||||
)
|
||||
}
|
||||
|
||||
const selectedCollapsed = ref(false)
|
||||
const expandedCategory = ref(selectedCategory.value)
|
||||
// Skips the watch when selectCategory/collapseCategory set selectedCategory,
|
||||
// so their expandedCategory toggle isn't immediately undone.
|
||||
let lastEmittedCategory = ''
|
||||
|
||||
watch(selectedCategory, (val) => {
|
||||
if (val !== lastEmittedCategory) {
|
||||
expandedCategory.value = val
|
||||
}
|
||||
lastEmittedCategory = ''
|
||||
})
|
||||
|
||||
function parentCategory(key: string): string {
|
||||
const i = key.lastIndexOf('/')
|
||||
return i > 0 ? key.slice(0, i) : ''
|
||||
}
|
||||
|
||||
function selectCategory(categoryId: string) {
|
||||
if (selectedCategory.value === categoryId) {
|
||||
selectedCollapsed.value = !selectedCollapsed.value
|
||||
if (expandedCategory.value === categoryId) {
|
||||
expandedCategory.value = parentCategory(categoryId)
|
||||
} else {
|
||||
selectedCollapsed.value = false
|
||||
selectedCategory.value = categoryId
|
||||
expandedCategory.value = categoryId
|
||||
}
|
||||
lastEmittedCategory = categoryId
|
||||
selectedCategory.value = categoryId
|
||||
}
|
||||
|
||||
function collapseCategory(categoryId: string) {
|
||||
expandedCategory.value = parentCategory(categoryId)
|
||||
lastEmittedCategory = categoryId
|
||||
selectedCategory.value = categoryId
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,32 +1,66 @@
|
||||
<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-sm 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-lg 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>
|
||||
<RovingFocusItem as-child>
|
||||
<Button
|
||||
ref="buttonEl"
|
||||
type="button"
|
||||
role="treeitem"
|
||||
:data-testid="`category-${node.key}`"
|
||||
:aria-current="selectedCategory === node.key || undefined"
|
||||
:aria-expanded="node.children?.length ? isExpanded : undefined"
|
||||
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
|
||||
:class="
|
||||
cn(
|
||||
'h-auto w-full gap-2 bg-transparent py-2.5 pr-3 text-left text-sm font-normal',
|
||||
selectedCategory === node.key
|
||||
? CATEGORY_SELECTED_CLASS
|
||||
: CATEGORY_UNSELECTED_CLASS
|
||||
)
|
||||
"
|
||||
@click="$emit('select', node.key)"
|
||||
@keydown.right.prevent="handleRight"
|
||||
@keydown.left.prevent="handleLeft"
|
||||
>
|
||||
<i
|
||||
v-if="!hideChevrons"
|
||||
:class="
|
||||
cn(
|
||||
'size-4 shrink-0 text-muted-foreground transition-[transform,opacity] duration-150',
|
||||
node.children?.length
|
||||
? 'icon-[lucide--chevron-down] opacity-0 group-hover/categories:opacity-100 group-has-focus-visible/categories:opacity-100'
|
||||
: '',
|
||||
node.children?.length && !isExpanded && '-rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ node.label }}</span>
|
||||
</Button>
|
||||
</RovingFocusItem>
|
||||
<div v-if="isExpanded && node.children?.length" role="group">
|
||||
<NodeSearchCategoryTreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.key"
|
||||
ref="childRefs"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
:selected-category="selectedCategory"
|
||||
:expanded-category="expandedCategory"
|
||||
:hide-chevrons="hideChevrons"
|
||||
:focus-parent="focusSelf"
|
||||
@select="$emit('select', $event)"
|
||||
@collapse="$emit('collapse', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -37,34 +71,73 @@ export interface CategoryNode {
|
||||
}
|
||||
|
||||
export const CATEGORY_SELECTED_CLASS =
|
||||
'bg-secondary-background-hover font-semibold text-foreground'
|
||||
'bg-secondary-background-hover text-foreground'
|
||||
export const CATEGORY_UNSELECTED_CLASS =
|
||||
'text-muted-foreground hover:bg-secondary-background-hover hover:text-foreground'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { RovingFocusItem } from 'reka-ui'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
node,
|
||||
depth = 0,
|
||||
selectedCategory,
|
||||
selectedCollapsed = false
|
||||
expandedCategory,
|
||||
hideChevrons = false,
|
||||
focusParent
|
||||
} = defineProps<{
|
||||
node: CategoryNode
|
||||
depth?: number
|
||||
selectedCategory: string
|
||||
selectedCollapsed?: boolean
|
||||
expandedCategory: string
|
||||
hideChevrons?: boolean
|
||||
focusParent?: () => void
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
select: [key: string]
|
||||
collapse: [key: string]
|
||||
}>()
|
||||
|
||||
const isExpanded = computed(() => {
|
||||
if (selectedCategory === node.key) return !selectedCollapsed
|
||||
return selectedCategory.startsWith(node.key + '/')
|
||||
})
|
||||
const buttonEl = ref<InstanceType<typeof Button>>()
|
||||
const childRefs = ref<{ focus?: () => void }[]>([])
|
||||
|
||||
function focusSelf() {
|
||||
const el = buttonEl.value?.$el as HTMLElement | undefined
|
||||
el?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focus: focusSelf })
|
||||
|
||||
const isExpanded = computed(
|
||||
() =>
|
||||
expandedCategory === node.key || expandedCategory.startsWith(node.key + '/')
|
||||
)
|
||||
|
||||
function handleRight() {
|
||||
if (!node.children?.length) return
|
||||
if (!isExpanded.value) {
|
||||
emit('select', node.key)
|
||||
return
|
||||
}
|
||||
nextTick(() => {
|
||||
childRefs.value[0]?.focus?.()
|
||||
})
|
||||
}
|
||||
|
||||
function handleLeft() {
|
||||
if (node.children?.length && isExpanded.value) {
|
||||
if (expandedCategory.startsWith(node.key + '/')) {
|
||||
emit('collapse', node.key)
|
||||
} else {
|
||||
emit('select', node.key)
|
||||
}
|
||||
return
|
||||
}
|
||||
focusParent?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
|
||||
import {
|
||||
@@ -9,33 +8,28 @@ import {
|
||||
setupTestPinia,
|
||||
testI18n
|
||||
} from '@/components/searchbox/v2/__test__/testUtils'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: 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()
|
||||
}))
|
||||
}))
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
describe('NodeSearchContent', () => {
|
||||
beforeEach(() => {
|
||||
setupTestPinia()
|
||||
vi.restoreAllMocks()
|
||||
const settings = useSettingStore()
|
||||
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
|
||||
settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {}
|
||||
})
|
||||
|
||||
async function renderComponent(props = {}) {
|
||||
function renderComponent(props = {}) {
|
||||
const user = userEvent.setup()
|
||||
const onAddNode = vi.fn()
|
||||
const onHoverNode = vi.fn()
|
||||
const onRemoveFilter = vi.fn()
|
||||
const onRemoveFilter =
|
||||
vi.fn<(f: FuseFilterWithValue<ComfyNodeDefImpl, string>) => void>()
|
||||
const onAddFilter = vi.fn()
|
||||
render(NodeSearchContent, {
|
||||
props: {
|
||||
@@ -63,18 +57,40 @@ describe('NodeSearchContent', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
await nextTick()
|
||||
return { user, onAddNode, onHoverNode, onRemoveFilter, onAddFilter }
|
||||
}
|
||||
|
||||
function mockBookmarks(
|
||||
isBookmarked: boolean | ((node: ComfyNodeDefImpl) => boolean) = true,
|
||||
bookmarkList: string[] = []
|
||||
) {
|
||||
const bookmarkStore = useNodeBookmarkStore()
|
||||
if (typeof isBookmarked === 'function') {
|
||||
vi.spyOn(bookmarkStore, 'isBookmarked').mockImplementation(isBookmarked)
|
||||
} else {
|
||||
vi.spyOn(bookmarkStore, 'isBookmarked').mockReturnValue(isBookmarked)
|
||||
}
|
||||
vi.spyOn(bookmarkStore, 'bookmarks', 'get').mockReturnValue(bookmarkList)
|
||||
}
|
||||
|
||||
function clickFilterBarButton(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
text: string
|
||||
) {
|
||||
const btn = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.trim() === text)
|
||||
expect(btn, `Expected filter button "${text}"`).toBeDefined()
|
||||
return user.click(btn!)
|
||||
}
|
||||
|
||||
async function setupFavorites(
|
||||
nodes: Parameters<typeof createMockNodeDef>[0][]
|
||||
) {
|
||||
useNodeDefStore().updateNodeDefs(nodes.map(createMockNodeDef))
|
||||
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(true)
|
||||
const result = await renderComponent()
|
||||
await result.user.click(screen.getByTestId('category-favorites'))
|
||||
await nextTick()
|
||||
mockBookmarks(true, ['placeholder'])
|
||||
const result = renderComponent()
|
||||
await clickFilterBarButton(result.user, 'Bookmarked')
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -92,11 +108,13 @@ describe('NodeSearchContent', () => {
|
||||
useNodeDefStore().nodeDefsByName['FrequentNode']
|
||||
])
|
||||
|
||||
await renderComponent()
|
||||
renderComponent()
|
||||
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Frequent Node')
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Frequent Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show only bookmarked nodes when Favorites is selected', async () => {
|
||||
@@ -110,30 +128,31 @@ describe('NodeSearchContent', () => {
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockImplementation(
|
||||
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode'
|
||||
mockBookmarks(
|
||||
(node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode',
|
||||
['BookmarkedNode']
|
||||
)
|
||||
|
||||
const { user } = await renderComponent()
|
||||
await user.click(screen.getByTestId('category-favorites'))
|
||||
await nextTick()
|
||||
const { user } = renderComponent()
|
||||
await clickFilterBarButton(user, 'Bookmarked')
|
||||
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Bookmarked')
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Bookmarked')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show empty state when no bookmarks exist', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'Node1', display_name: 'Node One' })
|
||||
])
|
||||
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
|
||||
mockBookmarks(false, ['placeholder'])
|
||||
|
||||
const { user } = await renderComponent()
|
||||
await user.click(screen.getByTestId('category-favorites'))
|
||||
await nextTick()
|
||||
const { user } = renderComponent()
|
||||
await clickFilterBarButton(user, 'Bookmarked')
|
||||
|
||||
expect(screen.getByText('No results')).toBeInTheDocument()
|
||||
expect(await screen.findByText('No Results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show only CustomNodes when Extensions is selected', async () => {
|
||||
@@ -149,7 +168,6 @@ describe('NodeSearchContent', () => {
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(useNodeDefStore().nodeDefsByName['CoreNode'].nodeSource.type).toBe(
|
||||
NodeSourceType.Core
|
||||
@@ -158,16 +176,17 @@ describe('NodeSearchContent', () => {
|
||||
useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
|
||||
).toBe(NodeSourceType.CustomNodes)
|
||||
|
||||
const { user } = await renderComponent()
|
||||
await user.click(screen.getByTestId('category-extensions'))
|
||||
await nextTick()
|
||||
const { user } = renderComponent()
|
||||
await clickFilterBarButton(user, 'Extensions')
|
||||
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Custom Node')
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Custom Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide Essentials category when no essential nodes exist', async () => {
|
||||
it('should hide Essentials filter button when no essential nodes exist', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'RegularNode',
|
||||
@@ -175,10 +194,11 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
])
|
||||
|
||||
await renderComponent()
|
||||
expect(
|
||||
screen.queryByTestId('category-essentials')
|
||||
).not.toBeInTheDocument()
|
||||
renderComponent()
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).not.toContain('Essentials')
|
||||
})
|
||||
|
||||
it('should show only essential nodes when Essentials is selected', async () => {
|
||||
@@ -193,15 +213,70 @@ describe('NodeSearchContent', () => {
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const { user } = await renderComponent()
|
||||
await user.click(screen.getByTestId('category-essentials'))
|
||||
await nextTick()
|
||||
const { user } = renderComponent()
|
||||
await clickFilterBarButton(user, 'Essentials')
|
||||
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Essential Node')
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('Essential Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show only API nodes when Partner Nodes filter is active', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ApiNode',
|
||||
display_name: 'API Node',
|
||||
api_node: true
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'RegularNode',
|
||||
display_name: 'Regular Node'
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
await clickFilterBarButton(user, 'Partner')
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId('node-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toHaveTextContent('API Node')
|
||||
})
|
||||
})
|
||||
|
||||
it('should toggle filter off when clicking the active filter button again', 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'
|
||||
})
|
||||
])
|
||||
|
||||
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
|
||||
useNodeDefStore().nodeDefsByName['CoreNode'],
|
||||
useNodeDefStore().nodeDefsByName['CustomNode']
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
|
||||
await clickFilterBarButton(user, 'Extensions')
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('node-item')).toHaveLength(1)
|
||||
})
|
||||
|
||||
await clickFilterBarButton(user, 'Extensions')
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('node-item')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('should include subcategory nodes when parent category is selected', async () => {
|
||||
@@ -223,19 +298,20 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = await renderComponent()
|
||||
await user.click(screen.getByTestId('category-sampling'))
|
||||
await nextTick()
|
||||
const { user } = renderComponent()
|
||||
await user.click(await screen.findByTestId('category-sampling'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('node-item')).toHaveLength(2)
|
||||
})
|
||||
const texts = screen.getAllByTestId('node-item').map((i) => i.textContent)
|
||||
expect(texts).toHaveLength(2)
|
||||
expect(texts).toContain('KSampler')
|
||||
expect(texts).toContain('KSampler Advanced')
|
||||
})
|
||||
})
|
||||
|
||||
describe('search and category interaction', () => {
|
||||
it('should override category to most-relevant when search query is active', async () => {
|
||||
it('should search within selected category', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'KSampler',
|
||||
@@ -249,35 +325,38 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = await renderComponent()
|
||||
await user.click(screen.getByTestId('category-sampling'))
|
||||
await nextTick()
|
||||
const { user } = renderComponent()
|
||||
await user.click(await screen.findByTestId('category-sampling'))
|
||||
|
||||
expect(screen.getAllByTestId('node-item')).toHaveLength(1)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('node-item')).toHaveLength(1)
|
||||
})
|
||||
|
||||
const input = screen.getByRole('combobox')
|
||||
await user.type(input, 'Load')
|
||||
await nextTick()
|
||||
|
||||
const texts = screen.getAllByTestId('node-item').map((i) => i.textContent)
|
||||
expect(texts.some((t) => t?.includes('Load Checkpoint'))).toBe(true)
|
||||
await waitFor(() => {
|
||||
const texts = screen
|
||||
.queryAllByTestId('node-item')
|
||||
.map((i) => i.textContent)
|
||||
expect(texts.some((t) => t?.includes('Load Checkpoint'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear search query when category changes', async () => {
|
||||
it('should preserve search query when category changes', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({ name: 'TestNode', display_name: 'Test Node' })
|
||||
])
|
||||
mockBookmarks(true, ['placeholder'])
|
||||
|
||||
const { user } = await renderComponent()
|
||||
const { user } = renderComponent()
|
||||
|
||||
const input = screen.getByRole('combobox')
|
||||
await user.type(input, 'test query')
|
||||
await nextTick()
|
||||
expect(input).toHaveValue('test query')
|
||||
|
||||
await user.click(screen.getByTestId('category-favorites'))
|
||||
await nextTick()
|
||||
expect(input).toHaveValue('')
|
||||
await clickFilterBarButton(user, 'Bookmarked')
|
||||
expect(input).toHaveValue('test query')
|
||||
})
|
||||
|
||||
it('should reset selected index when search query changes', async () => {
|
||||
@@ -289,18 +368,20 @@ describe('NodeSearchContent', () => {
|
||||
const input = screen.getByRole('combobox')
|
||||
await user.click(input)
|
||||
await user.keyboard('{ArrowDown}')
|
||||
await nextTick()
|
||||
expect(screen.getAllByTestId('result-item')[1]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('result-item')[1]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
await user.type(input, 'Node')
|
||||
await nextTick()
|
||||
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset selected index when category changes', async () => {
|
||||
@@ -311,17 +392,16 @@ describe('NodeSearchContent', () => {
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.keyboard('{ArrowDown}')
|
||||
await nextTick()
|
||||
|
||||
await user.click(screen.getByTestId('category-most-relevant'))
|
||||
await nextTick()
|
||||
await user.click(screen.getByTestId('category-favorites'))
|
||||
await nextTick()
|
||||
await clickFilterBarButton(user, 'Bookmarked')
|
||||
await clickFilterBarButton(user, 'Bookmarked')
|
||||
|
||||
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('result-item')[0]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -342,24 +422,19 @@ describe('NodeSearchContent', () => {
|
||||
expect(selectedIndex()).toBe(0)
|
||||
|
||||
await user.keyboard('{ArrowDown}')
|
||||
await nextTick()
|
||||
expect(selectedIndex()).toBe(1)
|
||||
await waitFor(() => expect(selectedIndex()).toBe(1))
|
||||
|
||||
await user.keyboard('{ArrowDown}')
|
||||
await nextTick()
|
||||
expect(selectedIndex()).toBe(2)
|
||||
await waitFor(() => expect(selectedIndex()).toBe(2))
|
||||
|
||||
await user.keyboard('{ArrowUp}')
|
||||
await nextTick()
|
||||
expect(selectedIndex()).toBe(1)
|
||||
await waitFor(() => expect(selectedIndex()).toBe(1))
|
||||
|
||||
// Navigate to first, then try going above — should clamp
|
||||
await user.keyboard('{ArrowUp}')
|
||||
await nextTick()
|
||||
expect(selectedIndex()).toBe(0)
|
||||
await waitFor(() => expect(selectedIndex()).toBe(0))
|
||||
|
||||
await user.keyboard('{ArrowUp}')
|
||||
await nextTick()
|
||||
expect(selectedIndex()).toBe(0)
|
||||
})
|
||||
|
||||
@@ -370,7 +445,6 @@ describe('NodeSearchContent', () => {
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.keyboard('{Enter}')
|
||||
await nextTick()
|
||||
|
||||
expect(onAddNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'TestNode' })
|
||||
@@ -385,9 +459,10 @@ describe('NodeSearchContent', () => {
|
||||
|
||||
const results = screen.getAllByTestId('result-item')
|
||||
await user.hover(results[1])
|
||||
await nextTick()
|
||||
|
||||
expect(results[1]).toHaveAttribute('aria-selected', 'true')
|
||||
await waitFor(() => {
|
||||
expect(results[1]).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should add node on click', async () => {
|
||||
@@ -396,13 +471,54 @@ describe('NodeSearchContent', () => {
|
||||
])
|
||||
|
||||
await user.click(screen.getAllByTestId('result-item')[0])
|
||||
await nextTick()
|
||||
|
||||
expect(onAddNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'TestNode' }),
|
||||
expect.any(PointerEvent)
|
||||
)
|
||||
})
|
||||
|
||||
it('should navigate results with ArrowDown/ArrowUp from a focused result item', async () => {
|
||||
const { user } = await setupFavorites([
|
||||
{ name: 'Node1', display_name: 'Node One' },
|
||||
{ name: 'Node2', display_name: 'Node Two' },
|
||||
{ name: 'Node3', display_name: 'Node Three' }
|
||||
])
|
||||
|
||||
const results = screen.getAllByTestId('result-item')
|
||||
results[0].focus()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('result-item')[1]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
screen.getAllByTestId('result-item')[1].focus()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('result-item')[2]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should select node with Enter from a focused result item', async () => {
|
||||
const { user, onAddNode } = await setupFavorites([
|
||||
{ name: 'TestNode', display_name: 'Test Node' }
|
||||
])
|
||||
|
||||
screen.getAllByTestId('result-item')[0].focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(onAddNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'TestNode' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hoverNode emission', () => {
|
||||
@@ -411,26 +527,27 @@ describe('NodeSearchContent', () => {
|
||||
{ name: 'HoverNode', display_name: 'Hover Node' }
|
||||
])
|
||||
|
||||
const calls = onHoverNode.mock.calls
|
||||
expect(calls[calls.length - 1][0]).toMatchObject({
|
||||
name: 'HoverNode'
|
||||
await waitFor(() => {
|
||||
const calls = onHoverNode.mock.calls
|
||||
expect(calls[calls.length - 1][0]).toMatchObject({ name: 'HoverNode' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit null hoverNode when no results', async () => {
|
||||
const { user, onHoverNode } = await renderComponent()
|
||||
mockBookmarks(false, ['placeholder'])
|
||||
const { user, onHoverNode } = renderComponent()
|
||||
|
||||
vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
|
||||
await user.click(screen.getByTestId('category-favorites'))
|
||||
await nextTick()
|
||||
await clickFilterBarButton(user, 'Bookmarked')
|
||||
|
||||
const calls = onHoverNode.mock.calls
|
||||
expect(calls[calls.length - 1][0]).toBeNull()
|
||||
await waitFor(() => {
|
||||
const calls = onHoverNode.mock.calls
|
||||
expect(calls[calls.length - 1][0]).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter integration', () => {
|
||||
it('should display active filters in the input area', async () => {
|
||||
it('should display active filters in the input area', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ImageNode',
|
||||
@@ -439,7 +556,7 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
])
|
||||
|
||||
await renderComponent({
|
||||
renderComponent({
|
||||
filters: [
|
||||
{
|
||||
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
|
||||
@@ -474,13 +591,11 @@ describe('NodeSearchContent', () => {
|
||||
|
||||
it('should emit removeFilter on backspace', async () => {
|
||||
const filters = createFilters(1)
|
||||
const { user, onRemoveFilter } = await renderComponent({ filters })
|
||||
const { user, onRemoveFilter } = renderComponent({ filters })
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.keyboard('{Backspace}')
|
||||
await nextTick()
|
||||
await user.keyboard('{Backspace}')
|
||||
await nextTick()
|
||||
|
||||
expect(onRemoveFilter).toHaveBeenCalledTimes(1)
|
||||
expect(onRemoveFilter).toHaveBeenCalledWith(
|
||||
@@ -489,247 +604,102 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
|
||||
it('should not interact with chips when no filters exist', async () => {
|
||||
const { user, onRemoveFilter } = await renderComponent({ filters: [] })
|
||||
const { user, onRemoveFilter } = renderComponent({ filters: [] })
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.keyboard('{Backspace}')
|
||||
await nextTick()
|
||||
|
||||
expect(onRemoveFilter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove chip when clicking its delete button', async () => {
|
||||
const filters = createFilters(1)
|
||||
const { user, onRemoveFilter } = await renderComponent({ filters })
|
||||
const { user, onRemoveFilter } = renderComponent({ filters })
|
||||
|
||||
await user.click(screen.getByTestId('chip-delete'))
|
||||
await nextTick()
|
||||
|
||||
expect(onRemoveFilter).toHaveBeenCalledTimes(1)
|
||||
expect(onRemoveFilter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: 'IMAGE' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter selection mode', () => {
|
||||
function setupNodesWithTypes() {
|
||||
it('should emit removeFilter for every filter in a group when cleared', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ImageNode',
|
||||
display_name: 'Image Node',
|
||||
input: { required: { image: ['IMAGE', {}] } },
|
||||
output: ['IMAGE']
|
||||
input: { required: { image: ['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']
|
||||
input: { required: { latent: ['LATENT', {}] } }
|
||||
})
|
||||
])
|
||||
}
|
||||
const inputFilter = useNodeDefStore().nodeSearchService.inputTypeFilter
|
||||
const filters = [
|
||||
{ filterDef: inputFilter, value: 'IMAGE' },
|
||||
{ filterDef: inputFilter, value: 'LATENT' }
|
||||
]
|
||||
|
||||
function findFilterBarButton(label: string) {
|
||||
return screen.getAllByRole('button').find((b) => b.textContent === label)
|
||||
}
|
||||
const { user, onRemoveFilter } = renderComponent({ filters })
|
||||
|
||||
async function enterFilterMode(user: ReturnType<typeof userEvent.setup>) {
|
||||
const btn = findFilterBarButton('Input')
|
||||
expect(btn).toBeDefined()
|
||||
await user.click(btn!)
|
||||
await nextTick()
|
||||
}
|
||||
const inputBtn = screen.getByRole('button', { name: /Input/ })
|
||||
await user.click(inputBtn)
|
||||
|
||||
function hasSidebar() {
|
||||
return screen.queryByTestId('category-most-relevant') !== null
|
||||
}
|
||||
const clearBtn = await screen.findByRole('button', { name: 'Clear all' })
|
||||
await user.click(clearBtn)
|
||||
|
||||
it('should enter filter mode when a filter chip is selected', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
|
||||
expect(hasSidebar()).toBe(true)
|
||||
|
||||
await enterFilterMode(user)
|
||||
|
||||
expect(hasSidebar()).toBe(false)
|
||||
expect(screen.getAllByTestId('filter-option').length).toBeGreaterThan(0)
|
||||
await waitFor(() => {
|
||||
expect(onRemoveFilter).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
const removedValues = onRemoveFilter.mock.calls.map(([f]) => f.value)
|
||||
expect(removedValues).toEqual(expect.arrayContaining(['IMAGE', 'LATENT']))
|
||||
})
|
||||
})
|
||||
|
||||
it('should show available filter options sorted alphabetically', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
const texts = screen.getAllByTestId('filter-option').map(
|
||||
(o) =>
|
||||
/* eslint-disable testing-library/no-node-access */
|
||||
(o.querySelectorAll('span')[0] as HTMLElement)?.textContent
|
||||
?.replace(/^[•·]\s*/, '')
|
||||
.trim() ?? ''
|
||||
/* eslint-enable testing-library/no-node-access */
|
||||
)
|
||||
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 { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'IMAGE')
|
||||
await nextTick()
|
||||
|
||||
const texts = screen.getAllByTestId('filter-option').map(
|
||||
(o) =>
|
||||
/* eslint-disable testing-library/no-node-access */
|
||||
(o.querySelectorAll('span')[0] as HTMLElement)?.textContent
|
||||
?.replace(/^[•·]\s*/, '')
|
||||
.trim() ?? ''
|
||||
/* eslint-enable testing-library/no-node-access */
|
||||
)
|
||||
expect(texts).toContain('IMAGE')
|
||||
expect(texts).not.toContain('MODEL')
|
||||
})
|
||||
|
||||
it('should show no results when filter query has no matches', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
await user.type(screen.getByRole('combobox'), 'NONEXISTENT_TYPE')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByText('No results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should emit addFilter when a filter option is clicked', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user, onAddFilter } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
const imageOption = screen
|
||||
.getAllByTestId('filter-option')
|
||||
.find((o) => o.textContent?.includes('IMAGE'))
|
||||
await user.click(imageOption!)
|
||||
await nextTick()
|
||||
|
||||
expect(onAddFilter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filterDef: expect.objectContaining({ id: 'input' }),
|
||||
value: 'IMAGE'
|
||||
describe('rootFilter + category + search combination', () => {
|
||||
it('should intersect rootFilter, selected category, and search query', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'CustomSampler',
|
||||
display_name: 'Custom Sampler',
|
||||
category: 'sampling',
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'CustomLoader',
|
||||
display_name: 'Custom Loader',
|
||||
category: 'loaders',
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'CoreSampler',
|
||||
display_name: 'Core Sampler',
|
||||
category: 'sampling',
|
||||
python_module: 'nodes'
|
||||
})
|
||||
)
|
||||
})
|
||||
])
|
||||
|
||||
it('should exit filter mode after applying a filter', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getAllByTestId('filter-option')[0])
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(hasSidebar()).toBe(true)
|
||||
})
|
||||
|
||||
it('should emit addFilter when Enter is pressed on selected option', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user, onAddFilter } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await user.keyboard('{Enter}')
|
||||
await nextTick()
|
||||
|
||||
expect(onAddFilter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filterDef: expect.objectContaining({ id: 'input' }),
|
||||
value: 'IMAGE'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should navigate filter options with ArrowDown/ArrowUp', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
expect(screen.getAllByTestId('filter-option')[0]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
|
||||
await user.keyboard('{ArrowDown}')
|
||||
await nextTick()
|
||||
expect(screen.getAllByTestId('filter-option')[1]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
|
||||
await user.keyboard('{ArrowUp}')
|
||||
await nextTick()
|
||||
expect(screen.getAllByTestId('filter-option')[0]).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('should toggle filter mode off when same chip is clicked again', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
await user.click(findFilterBarButton('Input')!)
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(hasSidebar()).toBe(true)
|
||||
})
|
||||
|
||||
it('should reset filter query when re-entering filter mode', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
await clickFilterBarButton(user, 'Extensions')
|
||||
const samplingBtn = await screen.findByTestId('category-custom/sampling')
|
||||
await user.click(samplingBtn)
|
||||
|
||||
const input = screen.getByRole('combobox')
|
||||
await user.type(input, 'IMAGE')
|
||||
await nextTick()
|
||||
await user.type(input, 'Custom')
|
||||
|
||||
await user.click(findFilterBarButton('Input')!)
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
await enterFilterMode(user)
|
||||
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should exit filter mode when cancel button is clicked', async () => {
|
||||
setupNodesWithTypes()
|
||||
const { user } = await renderComponent()
|
||||
await enterFilterMode(user)
|
||||
|
||||
expect(hasSidebar()).toBe(false)
|
||||
|
||||
await user.click(screen.getByTestId('cancel-filter'))
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(hasSidebar()).toBe(true)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('node-item')).toHaveLength(1)
|
||||
})
|
||||
const texts = screen
|
||||
.queryAllByTestId('node-item')
|
||||
.map((i) => i.textContent)
|
||||
expect(texts).toContain('Custom Sampler')
|
||||
expect(texts).not.toContain('Core Sampler')
|
||||
expect(texts).not.toContain('Custom Loader')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,107 +1,129 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dialogRef"
|
||||
class="flex max-h-[50vh] min-h-[400px] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
|
||||
>
|
||||
<!-- Search input row -->
|
||||
<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"
|
||||
/>
|
||||
|
||||
<!-- Filter header row -->
|
||||
<div class="flex items-center">
|
||||
<NodeSearchFilterBar
|
||||
class="flex-1"
|
||||
:active-chip-key="activeFilter?.key"
|
||||
@select-chip="onSelectFilterChip"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<!-- Category sidebar (hidden in filter mode) -->
|
||||
<NodeSearchCategorySidebar
|
||||
v-if="!activeFilter"
|
||||
v-model:selected-category="sidebarCategory"
|
||||
class="w-52 shrink-0"
|
||||
<FocusScope as-child loop>
|
||||
<div
|
||||
ref="dialogRef"
|
||||
class="flex h-[min(80vh,750px)] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
|
||||
>
|
||||
<!-- Search input row -->
|
||||
<NodeSearchInput
|
||||
ref="searchInputRef"
|
||||
v-model:search-query="searchQuery"
|
||||
:filters="filters"
|
||||
@remove-filter="emit('removeFilter', $event)"
|
||||
@navigate-down="navigateResults(1)"
|
||||
@navigate-up="navigateResults(-1)"
|
||||
@select-current="selectCurrentResult"
|
||||
/>
|
||||
|
||||
<!-- Filter options list (filter selection mode) -->
|
||||
<NodeSearchFilterPanel
|
||||
v-if="activeFilter"
|
||||
ref="filterPanelRef"
|
||||
v-model:query="filterQuery"
|
||||
:chip="activeFilter"
|
||||
@apply="onFilterApply"
|
||||
/>
|
||||
<!-- Filter header row -->
|
||||
<div class="flex items-center">
|
||||
<NodeSearchFilterBar
|
||||
class="flex-1"
|
||||
:filters="filters"
|
||||
:active-category="rootFilter"
|
||||
:has-favorites="nodeBookmarkStore.bookmarks.length > 0"
|
||||
:has-essential-nodes="nodeAvailability.essential"
|
||||
:has-blueprint-nodes="nodeAvailability.blueprint"
|
||||
:has-partner-nodes="nodeAvailability.partner"
|
||||
:has-custom-nodes="nodeAvailability.custom"
|
||||
@toggle-filter="onToggleFilter"
|
||||
@clear-filter-group="onClearFilterGroup"
|
||||
@focus-search="nextTick(() => searchInputRef?.focus())"
|
||||
@select-category="onSelectCategory"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Results list (normal mode) -->
|
||||
<div
|
||||
v-else
|
||||
id="results-list"
|
||||
role="listbox"
|
||||
class="flex-1 overflow-y-auto py-2"
|
||||
>
|
||||
<!-- Content area -->
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<!-- Category sidebar -->
|
||||
<NodeSearchCategorySidebar
|
||||
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"
|
||||
@auto-expand="selectedCategory = $event"
|
||||
/>
|
||||
|
||||
<!-- Results list -->
|
||||
<div
|
||||
v-for="(node, index) in displayedResults"
|
||||
:id="`result-item-${index}`"
|
||||
:key="node.name"
|
||||
role="option"
|
||||
data-testid="result-item"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-14 cursor-pointer items-center px-4',
|
||||
index === selectedIndex && 'bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="emit('addNode', node, $event)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
id="results-list"
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
class="flex-1 overflow-y-auto py-2 pr-3 pl-1 select-none"
|
||||
@pointermove="onPointerMove"
|
||||
>
|
||||
<NodeSearchListItem
|
||||
:node-def="node"
|
||||
:current-query="searchQuery"
|
||||
show-description
|
||||
:show-source-badge="effectiveCategory !== 'essentials'"
|
||||
:hide-bookmark-icon="effectiveCategory === 'favorites'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="displayedResults.length === 0"
|
||||
class="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{{ $t('g.noResults') }}
|
||||
<div
|
||||
v-for="(node, index) in displayedResults"
|
||||
:id="`result-item-${index}`"
|
||||
:key="node.name"
|
||||
role="option"
|
||||
data-testid="result-item"
|
||||
:tabindex="index === selectedIndex ? 0 : -1"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-14 cursor-pointer items-center rounded-lg px-4 outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
index === selectedIndex && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="emit('addNode', node, $event)"
|
||||
@keydown.down.prevent="navigateResults(1, true)"
|
||||
@keydown.up.prevent="navigateResults(-1, true)"
|
||||
@keydown.enter.prevent="selectCurrentResult"
|
||||
>
|
||||
<NodeSearchListItem
|
||||
:node-def="node"
|
||||
:current-query="searchQuery"
|
||||
show-description
|
||||
:show-source-badge="rootFilter !== 'essentials'"
|
||||
:hide-bookmark-icon="selectedCategory === 'favorites'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="displayedResults.length === 0"
|
||||
class="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{{ $t('g.noResults') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusScope>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FocusScope } from 'reka-ui'
|
||||
import { computed, nextTick, 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 NodeSearchCategorySidebar, {
|
||||
DEFAULT_CATEGORY
|
||||
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.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 { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
BLUEPRINT_CATEGORY,
|
||||
isCustomNode,
|
||||
isEssentialNode
|
||||
} from '@/types/nodeSource'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
|
||||
{
|
||||
essentials: isEssentialNode,
|
||||
comfy: (n) => !isCustomNode(n),
|
||||
custom: isCustomNode
|
||||
}
|
||||
|
||||
const { filters } = defineProps<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
}>()
|
||||
@@ -113,57 +135,102 @@ const emit = defineEmits<{
|
||||
hoverNode: [nodeDef: ComfyNodeDefImpl | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeFrequencyStore = useNodeFrequencyStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
const nodeAvailability = computed(() => {
|
||||
let essential = false
|
||||
let blueprint = false
|
||||
let partner = false
|
||||
let custom = false
|
||||
for (const n of nodeDefStore.visibleNodeDefs) {
|
||||
if (!essential && flags.nodeLibraryEssentialsEnabled && isEssentialNode(n))
|
||||
essential = true
|
||||
if (!blueprint && n.category.startsWith(BLUEPRINT_CATEGORY))
|
||||
blueprint = true
|
||||
if (!partner && n.api_node) partner = true
|
||||
if (!custom && isCustomNode(n)) custom = true
|
||||
if (essential && blueprint && partner && custom) break
|
||||
}
|
||||
return { essential, blueprint, partner, custom }
|
||||
})
|
||||
|
||||
const dialogRef = ref<HTMLElement>()
|
||||
const searchInputRef = ref<InstanceType<typeof NodeSearchInput>>()
|
||||
const filterPanelRef = ref<InstanceType<typeof NodeSearchFilterPanel>>()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('most-relevant')
|
||||
const selectedCategory = ref(DEFAULT_CATEGORY)
|
||||
const selectedIndex = ref(0)
|
||||
|
||||
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 'favorites':
|
||||
return t('g.bookmarked')
|
||||
case BLUEPRINT_CATEGORY:
|
||||
return t('g.blueprints')
|
||||
case 'partner-nodes':
|
||||
return t('g.partner')
|
||||
case 'essentials':
|
||||
return t('g.essentials')
|
||||
case 'comfy':
|
||||
return t('g.comfy')
|
||||
case 'custom':
|
||||
return t('g.extensions')
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
const rootFilteredNodeDefs = computed(() => {
|
||||
if (!rootFilter.value) return nodeDefStore.visibleNodeDefs
|
||||
const allNodes = nodeDefStore.visibleNodeDefs
|
||||
const sourceFilter = sourceCategoryFilters[rootFilter.value]
|
||||
if (sourceFilter) return allNodes.filter(sourceFilter)
|
||||
switch (rootFilter.value) {
|
||||
case 'favorites':
|
||||
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
|
||||
case BLUEPRINT_CATEGORY:
|
||||
return allNodes.filter((n) => n.category.startsWith(rootFilter.value!))
|
||||
case 'partner-nodes':
|
||||
return allNodes.filter((n) => n.api_node)
|
||||
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 = DEFAULT_CATEGORY
|
||||
nextTick(() => searchInputRef.value?.focus())
|
||||
}
|
||||
|
||||
@@ -176,67 +243,68 @@ const searchResults = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const effectiveCategory = computed(() =>
|
||||
searchQuery.value ? 'most-relevant' : selectedCategory.value
|
||||
)
|
||||
|
||||
const sidebarCategory = computed({
|
||||
get: () => effectiveCategory.value,
|
||||
get: () => selectedCategory.value,
|
||||
set: (category: string) => {
|
||||
selectedCategory.value = category
|
||||
searchQuery.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
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('/'))
|
||||
)
|
||||
|
||||
function getMostRelevantResults(baseNodes: ComfyNodeDefImpl[]) {
|
||||
if (searchQuery.value || filters.length > 0) {
|
||||
const searched = searchResults.value
|
||||
if (!rootFilter.value) return searched
|
||||
const rootSet = new Set(baseNodes.map((n) => n.name))
|
||||
return searched.filter((n) => rootSet.has(n.name))
|
||||
}
|
||||
return rootFilter.value ? baseNodes : nodeFrequencyStore.topNodeDefs
|
||||
}
|
||||
|
||||
function getCategoryResults(baseNodes: ComfyNodeDefImpl[], category: string) {
|
||||
if (rootFilter.value && category === rootFilter.value) return baseNodes
|
||||
const rootPrefix = rootFilter.value ? rootFilter.value + '/' : ''
|
||||
const categoryPath = category.startsWith(rootPrefix)
|
||||
? category.slice(rootPrefix.length)
|
||||
: category
|
||||
return baseNodes.filter((n) => {
|
||||
const nodeCategory = n.category.startsWith(rootPrefix)
|
||||
? n.category.slice(rootPrefix.length)
|
||||
: n.category
|
||||
return (
|
||||
nodeCategory === categoryPath ||
|
||||
nodeCategory.startsWith(categoryPath + '/')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
|
||||
const allNodes = nodeDefStore.visibleNodeDefs
|
||||
const baseNodes = rootFilteredNodeDefs.value
|
||||
const category = selectedCategory.value
|
||||
|
||||
let results: ComfyNodeDefImpl[]
|
||||
switch (effectiveCategory.value) {
|
||||
case 'most-relevant':
|
||||
return searchResults.value
|
||||
case 'favorites':
|
||||
results = allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
|
||||
break
|
||||
case 'essentials':
|
||||
results = allNodes.filter(
|
||||
(n) => n.nodeSource.type === NodeSourceType.Essentials
|
||||
)
|
||||
break
|
||||
case 'recents':
|
||||
return searchResults.value
|
||||
case 'blueprints':
|
||||
results = allNodes.filter(
|
||||
(n) => n.nodeSource.type === NodeSourceType.Blueprint
|
||||
)
|
||||
break
|
||||
case 'partner':
|
||||
results = allNodes.filter((n) => n.api_node)
|
||||
break
|
||||
case 'comfy':
|
||||
results = allNodes.filter(
|
||||
(n) => n.nodeSource.type === NodeSourceType.Core
|
||||
)
|
||||
break
|
||||
case 'extensions':
|
||||
results = allNodes.filter(
|
||||
(n) => n.nodeSource.type === NodeSourceType.CustomNodes
|
||||
)
|
||||
break
|
||||
default:
|
||||
results = allNodes.filter(
|
||||
(n) =>
|
||||
n.category === effectiveCategory.value ||
|
||||
n.category.startsWith(effectiveCategory.value + '/')
|
||||
)
|
||||
break
|
||||
if (category === DEFAULT_CATEGORY) return getMostRelevantResults(baseNodes)
|
||||
|
||||
const hasSearch = searchQuery.value || filters.length > 0
|
||||
let source: ComfyNodeDefImpl[]
|
||||
if (hasSearch) {
|
||||
const searched = searchResults.value
|
||||
if (rootFilter.value) {
|
||||
const rootSet = new Set(baseNodes.map((n) => n.name))
|
||||
source = searched.filter((n) => rootSet.has(n.name))
|
||||
} else {
|
||||
source = searched
|
||||
}
|
||||
} else {
|
||||
source = baseNodes
|
||||
}
|
||||
|
||||
return filters.length > 0 ? results.filter(matchesFilters) : results
|
||||
const sourceFilter = sourceCategoryFilters[category]
|
||||
if (sourceFilter) return source.filter(sourceFilter)
|
||||
return getCategoryResults(source, category)
|
||||
})
|
||||
|
||||
const hoveredNodeDef = computed(
|
||||
@@ -251,42 +319,28 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch([selectedCategory, searchQuery, () => filters], () => {
|
||||
watch([selectedCategory, searchQuery, rootFilter, () => filters.length], () => {
|
||||
selectedIndex.value = 0
|
||||
})
|
||||
|
||||
function onKeyDown() {
|
||||
if (activeFilter.value) {
|
||||
filterPanelRef.value?.navigate(1)
|
||||
} else {
|
||||
navigateResults(1)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
function navigateResults(direction: number, focusItem = false) {
|
||||
const newIndex = selectedIndex.value + direction
|
||||
if (newIndex >= 0 && newIndex < displayedResults.value.length) {
|
||||
selectedIndex.value = newIndex
|
||||
nextTick(() => {
|
||||
dialogRef.value
|
||||
?.querySelector(`#result-item-${newIndex}`)
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
const el = dialogRef.value?.querySelector(
|
||||
`#result-item-${newIndex}`
|
||||
) as HTMLElement | null
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
if (focusItem) el?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, within } from '@testing-library/vue'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -13,7 +13,11 @@ import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => undefined),
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
|
||||
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
|
||||
return undefined
|
||||
}),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}))
|
||||
@@ -33,57 +37,81 @@ describe(NodeSearchFilterBar, () => {
|
||||
|
||||
async function createRender(props = {}) {
|
||||
const user = userEvent.setup()
|
||||
const onSelectChip = vi.fn()
|
||||
const { container } = render(NodeSearchFilterBar, {
|
||||
props: { onSelectChip, ...props },
|
||||
global: { plugins: [testI18n] }
|
||||
const onSelectCategory = vi.fn()
|
||||
render(NodeSearchFilterBar, {
|
||||
props: { onSelectCategory, ...props },
|
||||
global: {
|
||||
plugins: [testI18n],
|
||||
stubs: {
|
||||
NodeSearchTypeFilterPopover: {
|
||||
template: '<div data-testid="popover"><slot /></div>',
|
||||
props: ['chip', 'selectedValues']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
await nextTick()
|
||||
const view = within(container as HTMLElement)
|
||||
return { user, onSelectChip, view }
|
||||
return { user, onSelectCategory }
|
||||
}
|
||||
|
||||
it('should render all filter chips', async () => {
|
||||
const { view } = await createRender()
|
||||
it('should render Extensions button and Input/Output popover triggers', async () => {
|
||||
await createRender({ hasCustomNodes: true })
|
||||
|
||||
const buttons = view.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(6)
|
||||
expect(buttons[0]).toHaveTextContent('Blueprints')
|
||||
expect(buttons[1]).toHaveTextContent('Partner Nodes')
|
||||
expect(buttons[2]).toHaveTextContent('Essentials')
|
||||
expect(buttons[3]).toHaveTextContent('Extensions')
|
||||
expect(buttons[4]).toHaveTextContent('Input')
|
||||
expect(buttons[5]).toHaveTextContent('Output')
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const texts = buttons.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Extensions')
|
||||
expect(texts).toContain('Input')
|
||||
expect(texts).toContain('Output')
|
||||
})
|
||||
|
||||
it('should mark active chip as pressed when activeChipKey matches', async () => {
|
||||
const { view } = await createRender({ activeChipKey: 'input' })
|
||||
it('should always render Comfy button', async () => {
|
||||
await createRender()
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Comfy')
|
||||
})
|
||||
|
||||
expect(view.getByRole('button', { name: 'Input' })).toHaveAttribute(
|
||||
it('should render conditional category buttons when matching nodes exist', async () => {
|
||||
await createRender({
|
||||
hasFavorites: true,
|
||||
hasEssentialNodes: true,
|
||||
hasBlueprintNodes: true,
|
||||
hasPartnerNodes: true
|
||||
})
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Bookmarked')
|
||||
expect(texts).toContain('Blueprints')
|
||||
expect(texts).toContain('Partner')
|
||||
expect(texts).toContain('Essentials')
|
||||
})
|
||||
|
||||
it('should not render Extensions button when no custom nodes exist', async () => {
|
||||
await createRender()
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).not.toContain('Extensions')
|
||||
})
|
||||
|
||||
it('should emit selectCategory when category button is clicked', async () => {
|
||||
const { user, onSelectCategory } = await createRender({
|
||||
hasCustomNodes: true
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Extensions' }))
|
||||
|
||||
expect(onSelectCategory).toHaveBeenCalledWith('custom')
|
||||
})
|
||||
|
||||
it('should apply active styling when activeCategory matches', async () => {
|
||||
await createRender({ activeCategory: 'custom', hasCustomNodes: true })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Extensions' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not mark chips as pressed when activeChipKey does not match', async () => {
|
||||
const { view } = await createRender({ activeChipKey: null })
|
||||
|
||||
view.getAllByRole('button').forEach((btn) => {
|
||||
expect(btn).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit selectChip with chip data when clicked', async () => {
|
||||
const { user, onSelectChip, view } = await createRender()
|
||||
|
||||
await user.click(view.getByRole('button', { name: 'Input' }))
|
||||
|
||||
expect(onSelectChip).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'input',
|
||||
label: 'Input',
|
||||
filter: expect.anything()
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 px-2 py-1.5">
|
||||
<div class="flex items-center gap-2.5 px-3">
|
||||
<!-- Category filter buttons -->
|
||||
<button
|
||||
v-for="chip in chips"
|
||||
:key="chip.key"
|
||||
v-for="btn in categoryButtons"
|
||||
:key="btn.id"
|
||||
type="button"
|
||||
:aria-pressed="activeChipKey === chip.key"
|
||||
:class="
|
||||
cn(
|
||||
'flex-auto cursor-pointer rounded-md border border-secondary-background px-3 py-1 text-sm transition-colors',
|
||||
activeChipKey === chip.key
|
||||
? 'text-foreground bg-secondary-background'
|
||||
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
|
||||
)
|
||||
"
|
||||
@click="emit('selectChip', chip)"
|
||||
:aria-pressed="activeCategory === btn.id"
|
||||
:class="chipClass(activeCategory === btn.id)"
|
||||
@click="emit('selectCategory', btn.id)"
|
||||
>
|
||||
{{ chip.label }}
|
||||
{{ btn.label }}
|
||||
</button>
|
||||
|
||||
<div class="h-5 w-px shrink-0 bg-border-subtle" />
|
||||
|
||||
<!-- Type filter popovers (Input / Output) -->
|
||||
<NodeSearchTypeFilterPopover
|
||||
v-for="tf in typeFilters"
|
||||
:key="tf.chip.key"
|
||||
:chip="tf.chip"
|
||||
:selected-values="tf.values"
|
||||
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
|
||||
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
|
||||
@escape-close="emit('focusSearch')"
|
||||
>
|
||||
<button type="button" :class="chipClass(false, tf.values.length > 0)">
|
||||
<span v-if="tf.values.length > 0" class="flex items-center">
|
||||
<span
|
||||
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
|
||||
:key="val"
|
||||
class="-mx-[2px] text-lg leading-none"
|
||||
:style="{ color: getLinkTypeColor(val) }"
|
||||
>•</span
|
||||
>
|
||||
</span>
|
||||
{{ tf.chip.label }}
|
||||
<i class="icon-[lucide--chevron-down] size-3.5" />
|
||||
</button>
|
||||
</NodeSearchTypeFilterPopover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -35,53 +56,97 @@ export interface FilterChip {
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { BLUEPRINT_CATEGORY } 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,
|
||||
hasFavorites = false,
|
||||
hasEssentialNodes = false,
|
||||
hasBlueprintNodes = false,
|
||||
hasPartnerNodes = false,
|
||||
hasCustomNodes = false
|
||||
} = defineProps<{
|
||||
filters?: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
activeCategory?: string | null
|
||||
hasFavorites?: boolean
|
||||
hasEssentialNodes?: boolean
|
||||
hasBlueprintNodes?: boolean
|
||||
hasPartnerNodes?: boolean
|
||||
hasCustomNodes?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectChip: [chip: FilterChip]
|
||||
toggleFilter: [filterDef: FuseFilter<ComfyNodeDefImpl, string>, value: string]
|
||||
clearFilterGroup: [filterId: string]
|
||||
focusSearch: []
|
||||
selectCategory: [category: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const chips = computed<FilterChip[]>(() => {
|
||||
const searchService = nodeDefStore.nodeSearchService
|
||||
return [
|
||||
{
|
||||
key: 'blueprints',
|
||||
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints'),
|
||||
filter: searchService.nodeSourceFilter
|
||||
},
|
||||
{
|
||||
key: 'partnerNodes',
|
||||
label: t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes'),
|
||||
filter: searchService.nodeSourceFilter
|
||||
},
|
||||
{
|
||||
key: 'essentials',
|
||||
label: t('g.essentials'),
|
||||
filter: searchService.nodeSourceFilter
|
||||
},
|
||||
{
|
||||
key: 'extensions',
|
||||
label: t('g.extensions'),
|
||||
filter: searchService.nodeSourceFilter
|
||||
},
|
||||
{
|
||||
key: 'input',
|
||||
label: t('g.input'),
|
||||
filter: searchService.inputTypeFilter
|
||||
},
|
||||
{
|
||||
key: 'output',
|
||||
label: t('g.output'),
|
||||
filter: searchService.outputTypeFilter
|
||||
}
|
||||
]
|
||||
const MAX_VISIBLE_DOTS = 4
|
||||
|
||||
const categoryButtons = computed(() => {
|
||||
const buttons: { id: string; label: string }[] = []
|
||||
if (hasFavorites) {
|
||||
buttons.push({ id: 'favorites', label: t('g.bookmarked') })
|
||||
}
|
||||
if (hasBlueprintNodes) {
|
||||
buttons.push({ id: BLUEPRINT_CATEGORY, label: t('g.blueprints') })
|
||||
}
|
||||
if (hasPartnerNodes) {
|
||||
buttons.push({ id: 'partner-nodes', label: t('g.partner') })
|
||||
}
|
||||
if (hasEssentialNodes) {
|
||||
buttons.push({ id: 'essentials', label: t('g.essentials') })
|
||||
}
|
||||
buttons.push({ id: 'comfy', label: t('g.comfy') })
|
||||
if (hasCustomNodes) {
|
||||
buttons.push({ id: 'custom', label: t('g.extensions') })
|
||||
}
|
||||
return buttons
|
||||
})
|
||||
|
||||
const inputChip = computed<FilterChip>(() => ({
|
||||
key: 'input',
|
||||
label: t('g.input'),
|
||||
filter: nodeDefStore.nodeSearchService.inputTypeFilter
|
||||
}))
|
||||
|
||||
const outputChip = computed<FilterChip>(() => ({
|
||||
key: 'output',
|
||||
label: t('g.output'),
|
||||
filter: nodeDefStore.nodeSearchService.outputTypeFilter
|
||||
}))
|
||||
|
||||
const selectedInputValues = computed(() =>
|
||||
filters.filter((f) => f.filterDef.id === 'input').map((f) => f.value)
|
||||
)
|
||||
|
||||
const selectedOutputValues = computed(() =>
|
||||
filters.filter((f) => f.filterDef.id === 'output').map((f) => f.value)
|
||||
)
|
||||
|
||||
const typeFilters = computed(() => [
|
||||
{ chip: inputChip.value, values: selectedInputValues.value },
|
||||
{ chip: outputChip.value, values: selectedOutputValues.value }
|
||||
])
|
||||
|
||||
function chipClass(isActive: boolean, hasSelections = false) {
|
||||
return cn(
|
||||
'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
|
||||
isActive
|
||||
? 'border-base-foreground bg-base-foreground text-base-background'
|
||||
: hasSelections
|
||||
? 'border-base-foreground/60 bg-transparent 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-foreground text-base font-semibold">
|
||||
<span class="mr-1 text-2xl" :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>
|
||||
@@ -2,7 +2,6 @@ import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
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,
|
||||
@@ -19,7 +18,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()
|
||||
}))
|
||||
}))
|
||||
@@ -40,20 +43,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()
|
||||
@@ -63,27 +52,19 @@ describe('NodeSearchInput', () => {
|
||||
function createRender(
|
||||
props: Partial<{
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
activeFilter: FilterChip | null
|
||||
searchQuery: string
|
||||
filterQuery: string
|
||||
}> = {}
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
const onUpdateSearchQuery = vi.fn()
|
||||
const onUpdateFilterQuery = vi.fn()
|
||||
const onCancelFilter = vi.fn()
|
||||
const onSelectCurrent = vi.fn()
|
||||
const onNavigateDown = vi.fn()
|
||||
const onNavigateUp = vi.fn()
|
||||
render(NodeSearchInput, {
|
||||
props: {
|
||||
filters: [],
|
||||
activeFilter: null,
|
||||
searchQuery: '',
|
||||
filterQuery: '',
|
||||
'onUpdate:searchQuery': onUpdateSearchQuery,
|
||||
'onUpdate:filterQuery': onUpdateFilterQuery,
|
||||
onCancelFilter,
|
||||
onSelectCurrent,
|
||||
onNavigateDown,
|
||||
onNavigateUp,
|
||||
@@ -94,43 +75,20 @@ describe('NodeSearchInput', () => {
|
||||
return {
|
||||
user,
|
||||
onUpdateSearchQuery,
|
||||
onUpdateFilterQuery,
|
||||
onCancelFilter,
|
||||
onSelectCurrent,
|
||||
onNavigateDown,
|
||||
onNavigateUp
|
||||
}
|
||||
}
|
||||
|
||||
it('should route input to searchQuery when no active filter', async () => {
|
||||
it('should route input to searchQuery', async () => {
|
||||
const { user, onUpdateSearchQuery } = createRender()
|
||||
await user.type(screen.getByRole('combobox'), 'test search')
|
||||
|
||||
expect(onUpdateSearchQuery).toHaveBeenLastCalledWith('test search')
|
||||
})
|
||||
|
||||
it('should route input to filterQuery when active filter is set', async () => {
|
||||
const { user, onUpdateFilterQuery, onUpdateSearchQuery } = createRender({
|
||||
activeFilter: createActiveFilter('Input')
|
||||
})
|
||||
await user.type(screen.getByRole('combobox'), 'IMAGE')
|
||||
|
||||
expect(onUpdateFilterQuery).toHaveBeenLastCalledWith('IMAGE')
|
||||
expect(onUpdateSearchQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show filter label placeholder when active filter is set', () => {
|
||||
createRender({
|
||||
activeFilter: createActiveFilter('Input')
|
||||
})
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveAttribute(
|
||||
'placeholder',
|
||||
expect.stringContaining('input')
|
||||
)
|
||||
})
|
||||
|
||||
it('should show add node placeholder when no active filter', () => {
|
||||
it('should show add node placeholder', () => {
|
||||
createRender()
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveAttribute(
|
||||
@@ -139,16 +97,7 @@ describe('NodeSearchInput', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide filter chips when active filter is set', () => {
|
||||
createRender({
|
||||
filters: [createFilter('input', 'IMAGE')],
|
||||
activeFilter: createActiveFilter('Input')
|
||||
})
|
||||
|
||||
expect(screen.queryAllByTestId('filter-chip')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should show filter chips when no active filter', () => {
|
||||
it('should show filter chips when filters are present', () => {
|
||||
createRender({
|
||||
filters: [createFilter('input', 'IMAGE')]
|
||||
})
|
||||
@@ -156,16 +105,6 @@ describe('NodeSearchInput', () => {
|
||||
expect(screen.getAllByTestId('filter-chip')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should emit cancelFilter when cancel button is clicked', async () => {
|
||||
const { user, onCancelFilter } = createRender({
|
||||
activeFilter: createActiveFilter('Input')
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('cancel-filter'))
|
||||
|
||||
expect(onCancelFilter).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should emit selectCurrent on Enter', async () => {
|
||||
const { user, onSelectCurrent } = createRender()
|
||||
|
||||
|
||||
@@ -7,61 +7,41 @@
|
||||
@remove-tag="onRemoveTag"
|
||||
@click="inputRef?.focus()"
|
||||
>
|
||||
<!-- Active filter label (filter selection mode) -->
|
||||
<span
|
||||
v-if="activeFilter"
|
||||
class="text-foreground -my-1 inline-flex shrink-0 items-center gap-1 rounded-lg bg-base-background px-2 py-1 text-sm opacity-80"
|
||||
>
|
||||
{{ activeFilter.label }}:
|
||||
<button
|
||||
type="button"
|
||||
data-testid="cancel-filter"
|
||||
class="aspect-square cursor-pointer rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="$t('g.remove')"
|
||||
@click="emit('cancelFilter')"
|
||||
>
|
||||
<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="-my-1 inline-flex items-center gap-1 rounded-lg bg-base-background px-2 py-1 data-[state=active]:ring-2 data-[state=active]:ring-primary"
|
||||
<TagsInputItem
|
||||
v-for="filter in filters"
|
||||
:key="filterKey(filter)"
|
||||
:value="filterKey(filter)"
|
||||
data-testid="filter-chip"
|
||||
class="-my-1 inline-flex items-center gap-1 rounded-lg bg-base-background px-2 py-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 flex aspect-square cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
|
||||
>
|
||||
<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 aspect-square cursor-pointer rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</TagsInputItemDelete>
|
||||
</TagsInputItem>
|
||||
</template>
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
</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"
|
||||
class="text-foreground h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
aria-controls="results-list"
|
||||
:aria-label="t('g.addNode')"
|
||||
:placeholder="t('g.addNode')"
|
||||
class="text-foreground h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent font-inter text-sm outline-none placeholder:text-muted-foreground"
|
||||
@keydown.enter.prevent="emit('selectCurrent')"
|
||||
@keydown.down.prevent="emit('navigateDown')"
|
||||
@keydown.up.prevent="emit('navigateUp')"
|
||||
@@ -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>) {
|
||||
|
||||
269
src/components/searchbox/v2/NodeSearchListItem.test.ts
Normal file
269
src/components/searchbox/v2/NodeSearchListItem.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
|
||||
import {
|
||||
createMockNodeDef,
|
||||
setupTestPinia,
|
||||
testI18n
|
||||
} from '@/components/searchbox/v2/__test__/testUtils'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
|
||||
function renderItem(
|
||||
props: Partial<ComponentProps<typeof NodeSearchListItem>> = {}
|
||||
) {
|
||||
return render(NodeSearchListItem, {
|
||||
props: { nodeDef: createMockNodeDef(), currentQuery: '', ...props },
|
||||
global: {
|
||||
plugins: [testI18n],
|
||||
stubs: {
|
||||
NodePricingBadge: {
|
||||
template: '<div data-testid="pricing-badge" />',
|
||||
props: ['nodeDef']
|
||||
},
|
||||
ComfyLogo: { template: '<div data-testid="comfy-logo" />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('NodeSearchListItem', () => {
|
||||
beforeEach(() => {
|
||||
setupTestPinia()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('id name badge', () => {
|
||||
it('shows id name when ShowIdName setting is enabled', () => {
|
||||
useSettingStore().settingValues['Comfy.NodeSearchBoxImpl.ShowIdName'] =
|
||||
true
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
name: 'KSamplerNode',
|
||||
display_name: 'KSampler'
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('KSamplerNode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides id name by default', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
name: 'KSamplerNode',
|
||||
display_name: 'KSampler'
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText('KSamplerNode')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('showDescription mode', () => {
|
||||
it('renders description text', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ description: 'A sampler node' }),
|
||||
showDescription: true
|
||||
})
|
||||
expect(screen.getByText('A sampler node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders category when ShowCategory setting is enabled', () => {
|
||||
useSettingStore().settingValues['Comfy.NodeSearchBoxImpl.ShowCategory'] =
|
||||
true
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ category: 'sampling/advanced' }),
|
||||
showDescription: true
|
||||
})
|
||||
expect(screen.getByText('sampling / advanced')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides category by default', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ category: 'sampling' }),
|
||||
showDescription: true
|
||||
})
|
||||
expect(screen.queryByText('sampling')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('source badge', () => {
|
||||
it('renders core comfy badge for non-custom node when showSourceBadge is true', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ python_module: 'nodes' }),
|
||||
showDescription: true,
|
||||
showSourceBadge: true
|
||||
})
|
||||
expect(screen.getByTestId('comfy-logo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders custom node badge for custom node when showSourceBadge is true', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
python_module: 'custom_nodes.my_extension',
|
||||
display_name: 'CustomNode'
|
||||
}),
|
||||
showDescription: true,
|
||||
showSourceBadge: true
|
||||
})
|
||||
expect(screen.getByText('my_extension')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render source badge when showSourceBadge is false', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ python_module: 'nodes' }),
|
||||
showDescription: true,
|
||||
showSourceBadge: false
|
||||
})
|
||||
expect(screen.queryByTestId('comfy-logo')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API node provider badge', () => {
|
||||
it('renders provider badge only when nodeDef.api_node is true', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
api_node: true,
|
||||
category: 'api/image/BFL'
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('BFL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render provider badge when nodeDef.api_node is false', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
api_node: false,
|
||||
category: 'api/image/BFL'
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText('BFL')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('status flags', () => {
|
||||
it('shows deprecated label when deprecated', () => {
|
||||
renderItem({ nodeDef: createMockNodeDef({ deprecated: true }) })
|
||||
expect(screen.getByText('DEPR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows experimental label when experimental', () => {
|
||||
renderItem({ nodeDef: createMockNodeDef({ experimental: true }) })
|
||||
expect(screen.getByText('BETA')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows devOnly label when dev_only is set', () => {
|
||||
renderItem({ nodeDef: createMockNodeDef({ dev_only: true }) })
|
||||
expect(screen.getByText('DEV')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show flags in description mode', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ deprecated: true, experimental: true }),
|
||||
showDescription: true
|
||||
})
|
||||
expect(screen.queryByText('DEPR')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('BETA')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('node frequency badge', () => {
|
||||
it('shows frequency when ShowNodeFrequency is enabled and frequency > 0', () => {
|
||||
useSettingStore().settingValues[
|
||||
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency'
|
||||
] = true
|
||||
vi.spyOn(useNodeFrequencyStore(), 'getNodeFrequency').mockReturnValue(
|
||||
1500
|
||||
)
|
||||
renderItem({ nodeDef: createMockNodeDef() })
|
||||
const badge = screen.getByTestId('frequency-badge')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(badge.textContent).toMatch(/1\.5k/i)
|
||||
})
|
||||
|
||||
it('hides frequency when frequency is 0 even if setting is enabled', () => {
|
||||
useSettingStore().settingValues[
|
||||
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency'
|
||||
] = true
|
||||
vi.spyOn(useNodeFrequencyStore(), 'getNodeFrequency').mockReturnValue(0)
|
||||
renderItem({ nodeDef: createMockNodeDef() })
|
||||
expect(screen.queryByTestId('frequency-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides frequency when setting is disabled even if frequency > 0', () => {
|
||||
useSettingStore().settingValues[
|
||||
'Comfy.NodeSearchBoxImpl.ShowNodeFrequency'
|
||||
] = false
|
||||
vi.spyOn(useNodeFrequencyStore(), 'getNodeFrequency').mockReturnValue(
|
||||
9999
|
||||
)
|
||||
renderItem({ nodeDef: createMockNodeDef() })
|
||||
expect(screen.queryByTestId('frequency-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bookmark icon', () => {
|
||||
it('shows bookmark icon when node is bookmarked', () => {
|
||||
useSettingStore().settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = [
|
||||
'TestNode'
|
||||
]
|
||||
renderItem({ nodeDef: createMockNodeDef({ name: 'TestNode' }) })
|
||||
expect(
|
||||
screen.getByRole('img', { name: 'Bookmarked' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show bookmark icon when node is not bookmarked', () => {
|
||||
renderItem({ nodeDef: createMockNodeDef({ name: 'TestNode' }) })
|
||||
expect(
|
||||
screen.queryByRole('img', { name: 'Bookmarked' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides bookmark icon when hideBookmarkIcon prop is true', () => {
|
||||
useSettingStore().settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = [
|
||||
'TestNode'
|
||||
]
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ name: 'TestNode' }),
|
||||
hideBookmarkIcon: true
|
||||
})
|
||||
expect(
|
||||
screen.queryByRole('img', { name: 'Bookmarked' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('query highlighting', () => {
|
||||
it('wraps matching portion of display_name in a highlight span', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ display_name: 'KSampler Advanced' }),
|
||||
currentQuery: 'Sampler'
|
||||
})
|
||||
expect(
|
||||
screen.getByText('Sampler', { selector: 'span.highlight' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not wrap anything when currentQuery is empty', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({ display_name: 'KSampler' }),
|
||||
currentQuery: ''
|
||||
})
|
||||
expect(
|
||||
screen.queryByText('KSampler', { selector: 'span.highlight' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('node source display text', () => {
|
||||
it('shows custom node source displayText in non-description mode', () => {
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
python_module: 'custom_nodes.my_extension'
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('my_extension')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,46 +2,81 @@
|
||||
<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="text-foreground flex items-center gap-2 font-semibold">
|
||||
<span v-if="isBookmarked && !hideBookmarkIcon">
|
||||
<i class="pi pi-bookmark-fill mr-1 text-sm" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
|
||||
<!-- Row 1: Name (left) + badges (right) -->
|
||||
<div class="text-foreground flex items-center gap-2 text-sm">
|
||||
<span
|
||||
v-if="isBookmarked && !hideBookmarkIcon"
|
||||
role="img"
|
||||
:aria-label="$t('g.bookmarked')"
|
||||
>
|
||||
<i aria-hidden="true" class="pi pi-bookmark-fill mr-1 text-sm" />
|
||||
</span>
|
||||
<span v-html="highlightQuery(nodeDef.display_name, currentQuery)" />
|
||||
<span v-if="showIdName"> </span>
|
||||
<span
|
||||
class="truncate"
|
||||
v-html="highlightQuery(nodeDef.display_name, currentQuery)"
|
||||
/>
|
||||
<span
|
||||
v-if="showIdName"
|
||||
class="rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
class="shrink-0 rounded-sm 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 && !isCustom"
|
||||
aria-hidden="true"
|
||||
class="flex size-[18px] shrink-0 items-center justify-center rounded-full bg-secondary-background-hover/80"
|
||||
>
|
||||
<ComfyLogo :size="10" mode="fill" color="currentColor" />
|
||||
</span>
|
||||
<span
|
||||
v-else-if="showSourceBadge && isCustom"
|
||||
:class="badgePillClass"
|
||||
>
|
||||
<span class="truncate text-2xs">
|
||||
{{ 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-2xs text-muted-foreground"
|
||||
class="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
showSourceBadge &&
|
||||
nodeDef.nodeSource.type !== NodeSourceType.Core &&
|
||||
nodeDef.nodeSource.type !== NodeSourceType.Unknown
|
||||
"
|
||||
class="border-border mr-0.5 inline-flex shrink-0 rounded-sm border bg-base-foreground/5 px-1.5 py-0.5 text-xs text-base-foreground/70"
|
||||
>
|
||||
{{ nodeDef.nodeSource.displayText }}
|
||||
<span v-if="showCategory" class="max-w-2/5 shrink-0 truncate">
|
||||
{{ nodeDef.category.replaceAll('/', ' / ') }}
|
||||
</span>
|
||||
<TextTicker v-if="nodeDef.description">
|
||||
<span
|
||||
v-if="nodeDef.description && showCategory"
|
||||
class="h-3 w-px shrink-0 bg-border-default"
|
||||
/>
|
||||
<TextTicker v-if="nodeDef.description" class="min-w-0 flex-1">
|
||||
{{ nodeDef.description }}
|
||||
</TextTicker>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showCategory"
|
||||
class="option-category truncate text-sm font-light text-muted"
|
||||
>
|
||||
{{ nodeDef.category.replaceAll('/', ' > ') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!showDescription" class="flex items-center gap-1">
|
||||
<span
|
||||
@@ -64,6 +99,7 @@
|
||||
</span>
|
||||
<span
|
||||
v-if="showNodeFrequency && nodeFrequency > 0"
|
||||
data-testid="frequency-badge"
|
||||
class="rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatNumberWithSuffix(nodeFrequency, { roundToInt: true }) }}
|
||||
@@ -82,14 +118,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 +147,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-hover/80 px-2'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const showCategory = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
|
||||
@@ -122,4 +167,6 @@ const nodeFrequency = computed(() =>
|
||||
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const isBookmarked = computed(() => nodeBookmarkStore.isBookmarked(nodeDef))
|
||||
const providerName = computed(() => getProviderName(nodeDef.category))
|
||||
const isCustom = computed(() => isCustomNodeDef(nodeDef))
|
||||
</script>
|
||||
|
||||
176
src/components/searchbox/v2/NodeSearchTypeFilterPopover.test.ts
Normal file
176
src/components/searchbox/v2/NodeSearchTypeFilterPopover.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { 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, () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function createRender(
|
||||
props: {
|
||||
chip?: FilterChip
|
||||
selectedValues?: string[]
|
||||
} = {}
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
const onToggle = vi.fn()
|
||||
const onClear = vi.fn()
|
||||
const onEscapeClose = vi.fn()
|
||||
render(NodeSearchTypeFilterPopover, {
|
||||
props: {
|
||||
chip: props.chip ?? createMockChip(),
|
||||
selectedValues: props.selectedValues ?? [],
|
||||
onToggle,
|
||||
onClear,
|
||||
onEscapeClose
|
||||
},
|
||||
slots: {
|
||||
default: '<button data-testid="trigger">Input</button>'
|
||||
},
|
||||
global: { plugins: [testI18n] }
|
||||
})
|
||||
return { user, onToggle, onClear, onEscapeClose }
|
||||
}
|
||||
|
||||
async function openPopover(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByTestId('trigger'))
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('should render the trigger slot', () => {
|
||||
createRender()
|
||||
expect(screen.getByTestId('trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show popover content when trigger is clicked', async () => {
|
||||
const { user } = createRender()
|
||||
await openPopover(user)
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display all options sorted alphabetically', async () => {
|
||||
const { user } = createRender({
|
||||
chip: createMockChip(['MODEL', 'IMAGE', 'LATENT'])
|
||||
})
|
||||
await openPopover(user)
|
||||
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(options).toHaveLength(3)
|
||||
const texts = options.map((o) => o.textContent?.trim())
|
||||
expect(texts[0]).toContain('IMAGE')
|
||||
expect(texts[1]).toContain('LATENT')
|
||||
expect(texts[2]).toContain('MODEL')
|
||||
})
|
||||
|
||||
it('should show selected count text', async () => {
|
||||
const { user } = createRender({ selectedValues: ['IMAGE', 'LATENT'] })
|
||||
await openPopover(user)
|
||||
|
||||
expect(screen.getByText(/2 items selected/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show clear all button only when values are selected', async () => {
|
||||
const { user } = createRender({ selectedValues: [] })
|
||||
await openPopover(user)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const clearBtn = buttons.find((b) => b.textContent?.includes('Clear all'))
|
||||
expect(clearBtn).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should show clear all button when values are selected', async () => {
|
||||
const { user } = createRender({ selectedValues: ['IMAGE'] })
|
||||
await openPopover(user)
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Clear all'))
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should emit clear when clear all button is clicked', async () => {
|
||||
const { user, onClear } = createRender({ selectedValues: ['IMAGE'] })
|
||||
await openPopover(user)
|
||||
|
||||
const clearBtn = screen
|
||||
.getAllByRole('button')
|
||||
.find((b) => b.textContent?.includes('Clear all'))!
|
||||
await user.click(clearBtn)
|
||||
await nextTick()
|
||||
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should emit toggle when an option is clicked', async () => {
|
||||
const { user, onToggle } = createRender()
|
||||
await openPopover(user)
|
||||
|
||||
await user.click(screen.getAllByRole('option')[0])
|
||||
await nextTick()
|
||||
|
||||
expect(onToggle).toHaveBeenCalledWith('IMAGE')
|
||||
})
|
||||
|
||||
it('should filter options via search input', async () => {
|
||||
const { user } = createRender()
|
||||
await openPopover(user)
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'IMAGE')
|
||||
await nextTick()
|
||||
|
||||
const options = screen.getAllByRole('option')
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0].textContent).toContain('IMAGE')
|
||||
})
|
||||
|
||||
it('should show no results when search matches nothing', async () => {
|
||||
const { user } = createRender()
|
||||
await openPopover(user)
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'NONEXISTENT')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryAllByRole('option')).toHaveLength(0)
|
||||
expect(screen.getByText('No Results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should emit escapeClose and close the popover when Escape is pressed', async () => {
|
||||
const { user, onEscapeClose } = createRender()
|
||||
await openPopover(user)
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(onEscapeClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
175
src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue
Normal file
175
src/components/searchbox/v2/NodeSearchTypeFilterPopover.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<PopoverRoot v-model:open="open" @update:open="onOpenChange">
|
||||
<PopoverTrigger as-child>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
:side-offset="4"
|
||||
:collision-padding="10"
|
||||
class="data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-1001 w-64 rounded-lg border border-border-default bg-base-background px-4 py-1 shadow-interface will-change-[transform,opacity]"
|
||||
@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-sm 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="text-foreground size-full border-none bg-transparent font-inter text-sm 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',
|
||||
{ count: selectedValues.length },
|
||||
selectedValues.length
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
v-if="selectedValues.length > 0"
|
||||
type="button"
|
||||
class="cursor-pointer border-none bg-transparent font-inter 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"
|
||||
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"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-default',
|
||||
selectedSet.has(option) &&
|
||||
'text-primary-foreground border-primary bg-primary'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="selectedSet.has(option)"
|
||||
class="icon-[lucide--check] size-3"
|
||||
/>
|
||||
</span>
|
||||
<span class="truncate">{{ option }}</span>
|
||||
<span
|
||||
class="mr-1 ml-auto text-lg leading-none"
|
||||
: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,
|
||||
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 selectedSet = computed(() => new Set(selectedValues))
|
||||
|
||||
function onSelectionChange(value: AcceptableValue) {
|
||||
const newValues = value as string[]
|
||||
const added = newValues.find((v) => !selectedSet.value.has(v))
|
||||
const removed = selectedValues.find((v) => !newValues.includes(v))
|
||||
const toggled = added ?? removed
|
||||
if (toggled) emit('toggle', toggled)
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,12 +2,14 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
export function createMockNodeDef(
|
||||
overrides: Partial<ComfyNodeDef> = {}
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
): ComfyNodeDefImpl {
|
||||
return new ComfyNodeDefImpl({
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
category: 'test',
|
||||
@@ -21,7 +23,7 @@ export function createMockNodeDef(
|
||||
deprecated: false,
|
||||
experimental: false,
|
||||
...overrides
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function setupTestPinia() {
|
||||
@@ -31,34 +33,5 @@ export function setupTestPinia() {
|
||||
export const testI18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
addNode: 'Add a node...',
|
||||
filterBy: 'Filter by:',
|
||||
mostRelevant: 'Most relevant',
|
||||
recents: 'Recents',
|
||||
favorites: 'Favorites',
|
||||
essentials: 'Essentials',
|
||||
custom: 'Custom',
|
||||
comfy: 'Comfy',
|
||||
partner: 'Partner',
|
||||
extensions: 'Extensions',
|
||||
noResults: 'No results',
|
||||
filterByType: 'Filter by {type}...',
|
||||
input: 'Input',
|
||||
output: 'Output',
|
||||
source: 'Source',
|
||||
search: 'Search'
|
||||
},
|
||||
sideToolbar: {
|
||||
nodeLibraryTab: {
|
||||
filterOptions: {
|
||||
blueprints: 'Blueprints',
|
||||
partnerNodes: 'Partner Nodes'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
@@ -2223,6 +2223,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (this.state.ghostNodeId != null) {
|
||||
if (e.button === 0) this.finalizeGhostPlacement(false)
|
||||
if (e.button === 2) this.finalizeGhostPlacement(true)
|
||||
this.canvas.focus()
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
return
|
||||
@@ -3679,6 +3680,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
this.state.ghostNodeId = node.id
|
||||
this.dispatchEvent('litegraph:ghost-placement', {
|
||||
active: true,
|
||||
nodeId: node.id
|
||||
})
|
||||
|
||||
this.deselectAll()
|
||||
this.select(node)
|
||||
@@ -3709,6 +3714,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
this.state.ghostNodeId = null
|
||||
this.isDragging = false
|
||||
this.dispatchEvent('litegraph:ghost-placement', {
|
||||
active: false,
|
||||
nodeId
|
||||
})
|
||||
this._autoPan?.stop()
|
||||
this._autoPan = null
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
@@ -53,4 +53,10 @@ export interface LGraphCanvasEventMap {
|
||||
node: LGraphNode
|
||||
button: LGraphButton
|
||||
}
|
||||
|
||||
/** Ghost placement mode has started or ended. */
|
||||
'litegraph:ghost-placement': {
|
||||
active: boolean
|
||||
nodeId: NodeId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +208,7 @@
|
||||
"filterByType": "Filter by {type}...",
|
||||
"mostRelevant": "Most relevant",
|
||||
"favorites": "Favorites",
|
||||
"bookmarked": "Bookmarked",
|
||||
"essentials": "Essentials",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
@@ -367,6 +368,8 @@
|
||||
"preloadErrorTitle": "Loading Error",
|
||||
"recents": "Recents",
|
||||
"partner": "Partner",
|
||||
"blueprints": "Blueprints",
|
||||
"partnerNodes": "Partner Nodes",
|
||||
"collapseAll": "Collapse all",
|
||||
"expandAll": "Expand all"
|
||||
},
|
||||
|
||||
@@ -313,8 +313,7 @@
|
||||
"tooltip": "Only applies to the default implementation"
|
||||
},
|
||||
"Comfy_NodeSearchBoxImpl_ShowCategory": {
|
||||
"name": "Show node category in search results",
|
||||
"tooltip": "Only applies to v1 (legacy)"
|
||||
"name": "Show node category in search results"
|
||||
},
|
||||
"Comfy_NodeSearchBoxImpl_ShowIdName": {
|
||||
"name": "Show node id name in search results",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Raw } from 'vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
@@ -14,6 +15,7 @@ import type {
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { promoteRecommendedWidgets } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { app } from '@/scripts/app'
|
||||
import { isLGraphGroup, isLGraphNode, isReroute } from '@/utils/litegraphUtil'
|
||||
|
||||
@@ -114,6 +116,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
|
||||
const currentGraph = shallowRef<LGraph | null>(null)
|
||||
const isInSubgraph = ref(false)
|
||||
const isGhostPlacing = ref(false)
|
||||
|
||||
// Provide selection state to all Vue nodes
|
||||
const selectedNodeIds = computed(
|
||||
@@ -148,6 +151,18 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
(e: CustomEvent<{ subgraphNode: SubgraphNode }>) =>
|
||||
promoteRecommendedWidgets(e.detail.subgraphNode)
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
newCanvas.canvas,
|
||||
'litegraph:ghost-placement',
|
||||
(e: CustomEvent<{ active: boolean; nodeId: NodeId }>) => {
|
||||
isGhostPlacing.value = e.detail.active
|
||||
if (e.detail.active) {
|
||||
const mutations = useLayoutMutations()
|
||||
mutations.bringNodeToFront(String(e.detail.nodeId))
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -167,6 +182,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
initScaleSync,
|
||||
cleanupScaleSync,
|
||||
currentGraph,
|
||||
isInSubgraph
|
||||
isInSubgraph,
|
||||
isGhostPlacing
|
||||
}
|
||||
})
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
cursorClass,
|
||||
isSelected && 'outline-node-component-outline',
|
||||
executing && 'outline-node-stroke-executing',
|
||||
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
|
||||
shouldHandleNodePointerEvents &&
|
||||
!nodeData.flags?.ghost &&
|
||||
!isGhostPlacing
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
)
|
||||
@@ -27,6 +29,7 @@
|
||||
zIndex: zIndex,
|
||||
opacity: nodeOpacity
|
||||
}"
|
||||
:inert="isGhostPlacing"
|
||||
v-bind="remainingPointerHandlers"
|
||||
@pointerdown="nodeOnPointerdown"
|
||||
@wheel="handleWheel"
|
||||
@@ -348,7 +351,7 @@ const { bringNodeToFront } = useNodeZIndex()
|
||||
|
||||
useVueElementTracking(() => nodeData.id, 'node')
|
||||
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
const { selectedNodeIds, isGhostPlacing } = storeToRefs(useCanvasStore())
|
||||
const isSelected = computed(() => {
|
||||
return selectedNodeIds.value.has(nodeData.id)
|
||||
})
|
||||
|
||||
@@ -55,25 +55,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="badge in priceBadges ?? []" :key="badge.required">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex h-5 shrink-0 items-center bg-component-node-widget-background p-1 text-xs',
|
||||
badge.rest ? 'rounded-l-full pr-1' : 'rounded-full'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--component] h-full bg-amber-400" />
|
||||
<span class="truncate" v-text="badge.required" />
|
||||
</span>
|
||||
<span
|
||||
v-if="badge.rest"
|
||||
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
|
||||
>
|
||||
<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', () => {
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user