mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-19 22:34:15 +00:00
fix: prevent duplicate node search filters (#8935)
## Summary - Add duplicate check in `addFilter` to prevent identical filter chips (same `filterDef.id` and `value`) from being added to the node search box ## Related Issue - Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/3559 ## Changes - `NodeSearchBoxPopover.vue`: Guard `addFilter` with `isDuplicate` check comparing `filterDef.id` and `value` - `NodeSearchBoxPopover.test.ts`: Add unit tests covering duplicate prevention, distinct id, and distinct value cases ## QA - [x] `pnpm typecheck` passes - [x] `pnpm lint` passes - [x] `pnpm format:check` passes - [x] Unit tests pass (4/4) - [x] Bug reproduced with Playwright before fix ### as-is <img width="719" height="269" alt="스크린샷 2026-02-17 오후 5 45 48" src="https://github.com/user-attachments/assets/403bf53a-53dd-4257-945f-322717f304b3" /> ### to-be <img width="765" height="291" alt="스크린샷 2026-02-17 오후 5 44 25" src="https://github.com/user-attachments/assets/7995b15e-d071-4955-b054-5e0ca7c5c5bf" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8935-fix-prevent-duplicate-node-search-filters-30a6d73d3650816797cfcc524228f270) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -215,6 +215,14 @@ test.describe('Node search box', { tag: '@node' }, () => {
|
||||
await expectFilterChips(comfyPage, ['MODEL', 'CLIP'])
|
||||
})
|
||||
|
||||
test('Does not add duplicate filter with same type and value', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await expectFilterChips(comfyPage, ['MODEL'])
|
||||
})
|
||||
|
||||
test('Can remove filter', async ({ comfyPage }) => {
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await comfyPage.searchBox.removeFilter(0)
|
||||
|
||||
173
src/components/searchbox/NodeSearchBoxPopover.test.ts
Normal file
173
src/components/searchbox/NodeSearchBoxPopover.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
|
||||
|
||||
const mockStoreRefs = vi.hoisted(() => ({
|
||||
visible: { value: false },
|
||||
newSearchBoxEnabled: { value: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('pinia', async () => {
|
||||
const actual = await vi.importActual('pinia')
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
storeToRefs: () => mockStoreRefs
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/workspace/searchBoxStore', () => ({
|
||||
useSearchBoxStore: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({
|
||||
getCanvasCenter: vi.fn(() => [0, 0]),
|
||||
addNodeOnGraph: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: null,
|
||||
getCanvas: vi.fn(() => ({
|
||||
linkConnector: {
|
||||
events: new EventTarget(),
|
||||
renderLinks: []
|
||||
}
|
||||
}))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
nodeSearchService: {
|
||||
nodeFilters: [],
|
||||
inputTypeFilter: {},
|
||||
outputTypeFilter: {}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const NodeSearchBoxStub = defineComponent({
|
||||
name: 'NodeSearchBox',
|
||||
props: {
|
||||
filters: { type: Array, default: () => [] }
|
||||
},
|
||||
template: '<div class="node-search-box" />'
|
||||
})
|
||||
|
||||
function createFilter(
|
||||
id: string,
|
||||
value: string
|
||||
): FuseFilterWithValue<ComfyNodeDefImpl, string> {
|
||||
return {
|
||||
filterDef: { id } as FuseFilter<ComfyNodeDefImpl, string>,
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
describe('NodeSearchBoxPopover', () => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockStoreRefs.visible.value = false
|
||||
})
|
||||
|
||||
const mountComponent = () => {
|
||||
return mount(NodeSearchBoxPopover, {
|
||||
global: {
|
||||
plugins: [i18n, PrimeVue],
|
||||
stubs: {
|
||||
NodeSearchBox: NodeSearchBoxStub,
|
||||
Dialog: {
|
||||
template: '<div><slot name="container" /></div>',
|
||||
props: ['visible', 'modal', 'dismissableMask', 'pt']
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('addFilter duplicate prevention', () => {
|
||||
it('should add a filter when no duplicates exist', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const filters = searchBox.props('filters') as FuseFilterWithValue<
|
||||
ComfyNodeDefImpl,
|
||||
string
|
||||
>[]
|
||||
expect(filters).toHaveLength(1)
|
||||
expect(filters[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
filterDef: expect.objectContaining({ id: 'outputType' }),
|
||||
value: 'IMAGE'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should not add a duplicate filter with same id and value', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(searchBox.props('filters')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should allow filters with same id but different values', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'MASK'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(searchBox.props('filters')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should allow filters with different ids but same value', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
|
||||
|
||||
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
searchBox.vm.$emit('addFilter', createFilter('inputType', 'IMAGE'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(searchBox.props('filters')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -71,7 +71,12 @@ function getNewNodeLocation(): Point {
|
||||
}
|
||||
const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([])
|
||||
function addFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
|
||||
nodeFilters.value.push(filter)
|
||||
const isDuplicate = nodeFilters.value.some(
|
||||
(f) => f.filterDef.id === filter.filterDef.id && f.value === filter.value
|
||||
)
|
||||
if (!isDuplicate) {
|
||||
nodeFilters.value.push(filter)
|
||||
}
|
||||
}
|
||||
function removeFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
|
||||
nodeFilters.value = nodeFilters.value.filter(
|
||||
|
||||
Reference in New Issue
Block a user