Skip to content

Commit b47414a

Browse files
dante01yoonclaude
andauthored
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 #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>
1 parent 631d484 commit b47414a

File tree

3 files changed

+187
-1
lines changed

3 files changed

+187
-1
lines changed

browser_tests/tests/nodeSearchBox.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,14 @@ test.describe('Node search box', { tag: '@node' }, () => {
215215
await expectFilterChips(comfyPage, ['MODEL', 'CLIP'])
216216
})
217217

218+
test('Does not add duplicate filter with same type and value', async ({
219+
comfyPage
220+
}) => {
221+
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
222+
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
223+
await expectFilterChips(comfyPage, ['MODEL'])
224+
})
225+
218226
test('Can remove filter', async ({ comfyPage }) => {
219227
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
220228
await comfyPage.searchBox.removeFilter(0)
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { mount } from '@vue/test-utils'
2+
import { createPinia, setActivePinia } from 'pinia'
3+
import PrimeVue from 'primevue/config'
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { defineComponent } from 'vue'
6+
import { createI18n } from 'vue-i18n'
7+
8+
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
9+
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
10+
11+
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
12+
13+
const mockStoreRefs = vi.hoisted(() => ({
14+
visible: { value: false },
15+
newSearchBoxEnabled: { value: true }
16+
}))
17+
18+
vi.mock('@/platform/settings/settingStore', () => ({
19+
useSettingStore: () => ({
20+
get: vi.fn()
21+
})
22+
}))
23+
24+
vi.mock('pinia', async () => {
25+
const actual = await vi.importActual('pinia')
26+
return {
27+
...(actual as Record<string, unknown>),
28+
storeToRefs: () => mockStoreRefs
29+
}
30+
})
31+
32+
vi.mock('@/stores/workspace/searchBoxStore', () => ({
33+
useSearchBoxStore: () => ({})
34+
}))
35+
36+
vi.mock('@/services/litegraphService', () => ({
37+
useLitegraphService: () => ({
38+
getCanvasCenter: vi.fn(() => [0, 0]),
39+
addNodeOnGraph: vi.fn()
40+
})
41+
}))
42+
43+
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
44+
useWorkflowStore: () => ({
45+
activeWorkflow: null
46+
})
47+
}))
48+
49+
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
50+
useCanvasStore: () => ({
51+
canvas: null,
52+
getCanvas: vi.fn(() => ({
53+
linkConnector: {
54+
events: new EventTarget(),
55+
renderLinks: []
56+
}
57+
}))
58+
})
59+
}))
60+
61+
vi.mock('@/stores/nodeDefStore', () => ({
62+
useNodeDefStore: () => ({
63+
nodeSearchService: {
64+
nodeFilters: [],
65+
inputTypeFilter: {},
66+
outputTypeFilter: {}
67+
}
68+
})
69+
}))
70+
71+
const NodeSearchBoxStub = defineComponent({
72+
name: 'NodeSearchBox',
73+
props: {
74+
filters: { type: Array, default: () => [] }
75+
},
76+
template: '<div class="node-search-box" />'
77+
})
78+
79+
function createFilter(
80+
id: string,
81+
value: string
82+
): FuseFilterWithValue<ComfyNodeDefImpl, string> {
83+
return {
84+
filterDef: { id } as FuseFilter<ComfyNodeDefImpl, string>,
85+
value
86+
}
87+
}
88+
89+
describe('NodeSearchBoxPopover', () => {
90+
const i18n = createI18n({
91+
legacy: false,
92+
locale: 'en',
93+
messages: { en: {} }
94+
})
95+
96+
beforeEach(() => {
97+
setActivePinia(createPinia())
98+
mockStoreRefs.visible.value = false
99+
})
100+
101+
const mountComponent = () => {
102+
return mount(NodeSearchBoxPopover, {
103+
global: {
104+
plugins: [i18n, PrimeVue],
105+
stubs: {
106+
NodeSearchBox: NodeSearchBoxStub,
107+
Dialog: {
108+
template: '<div><slot name="container" /></div>',
109+
props: ['visible', 'modal', 'dismissableMask', 'pt']
110+
}
111+
}
112+
}
113+
})
114+
}
115+
116+
describe('addFilter duplicate prevention', () => {
117+
it('should add a filter when no duplicates exist', async () => {
118+
const wrapper = mountComponent()
119+
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
120+
121+
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
122+
await wrapper.vm.$nextTick()
123+
124+
const filters = searchBox.props('filters') as FuseFilterWithValue<
125+
ComfyNodeDefImpl,
126+
string
127+
>[]
128+
expect(filters).toHaveLength(1)
129+
expect(filters[0]).toEqual(
130+
expect.objectContaining({
131+
filterDef: expect.objectContaining({ id: 'outputType' }),
132+
value: 'IMAGE'
133+
})
134+
)
135+
})
136+
137+
it('should not add a duplicate filter with same id and value', async () => {
138+
const wrapper = mountComponent()
139+
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
140+
141+
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
142+
await wrapper.vm.$nextTick()
143+
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
144+
await wrapper.vm.$nextTick()
145+
146+
expect(searchBox.props('filters')).toHaveLength(1)
147+
})
148+
149+
it('should allow filters with same id but different values', async () => {
150+
const wrapper = mountComponent()
151+
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
152+
153+
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
154+
await wrapper.vm.$nextTick()
155+
searchBox.vm.$emit('addFilter', createFilter('outputType', 'MASK'))
156+
await wrapper.vm.$nextTick()
157+
158+
expect(searchBox.props('filters')).toHaveLength(2)
159+
})
160+
161+
it('should allow filters with different ids but same value', async () => {
162+
const wrapper = mountComponent()
163+
const searchBox = wrapper.findComponent(NodeSearchBoxStub)
164+
165+
searchBox.vm.$emit('addFilter', createFilter('outputType', 'IMAGE'))
166+
await wrapper.vm.$nextTick()
167+
searchBox.vm.$emit('addFilter', createFilter('inputType', 'IMAGE'))
168+
await wrapper.vm.$nextTick()
169+
170+
expect(searchBox.props('filters')).toHaveLength(2)
171+
})
172+
})
173+
})

src/components/searchbox/NodeSearchBoxPopover.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,12 @@ function getNewNodeLocation(): Point {
7171
}
7272
const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([])
7373
function addFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
74-
nodeFilters.value.push(filter)
74+
const isDuplicate = nodeFilters.value.some(
75+
(f) => f.filterDef.id === filter.filterDef.id && f.value === filter.value
76+
)
77+
if (!isDuplicate) {
78+
nodeFilters.value.push(filter)
79+
}
7580
}
7681
function removeFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
7782
nodeFilters.value = nodeFilters.value.filter(

0 commit comments

Comments
 (0)