Skip to content

Commit c56fff0

Browse files
authored
Workflow templates review (#5975)
This pull request introduces improvements to the workflow template selector and search box components, focusing on better user experience and more accurate terminology. The most significant changes include adding debounced search input handling, updating sorting option labels, and refining UI styling for consistency. **Search functionality improvements:** * Refactored `SearchBox.vue` to use an internal search query state and a debounced update mechanism, reducing unnecessary parent updates and improving responsiveness. The parent model is updated only after the user stops typing for 300ms. (`src/components/input/SearchBox.vue`) [[1]](diffhunk://#diff-08f3b0c51fbfe63171509b9944bf7558228f6c2596a1ef5338e88ab64585791bL6-R6) [[2]](diffhunk://#diff-08f3b0c51fbfe63171509b9944bf7558228f6c2596a1ef5338e88ab64585791bR39-R62) * Updated the search box in `WorkflowTemplateSelectorDialog.vue` to use the new debounced search model and increased its size for better visibility. (`src/components/custom/widget/WorkflowTemplateSelectorDialog.vue`) **Sorting and terminology updates:** * Changed sorting option labels to use more precise terminology, such as "VRAM Usage (Low to High)" and added new locale strings for sorting options. (`src/components/custom/widget/WorkflowTemplateSelectorDialog.vue`, `src/locales/en/main.json`) [[1]](diffhunk://#diff-2c860bdc48e907b1b85dbef846599d8376dd02cff90f49e490eebe61371fecedL623-R623) [[2]](diffhunk://#diff-bbf3da78aeff5b4d868a17a6960d109cb0627316cda2f9b5fa7c08e9abd93be6L1032-R1035) **UI and styling adjustments:** * Adjusted the width of the sorting dropdown for better alignment and consistency. (`src/components/custom/widget/WorkflowTemplateSelectorDialog.vue`) * Updated active navigation item background color for improved visual clarity. (`src/components/widget/nav/NavItem.vue`) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5975-Workflow-templates-review-2866d73d365081419257f9df2bab9c5b) by [Unito](https://www.unito.io) https://github.com/user-attachments/assets/4f72d515-f114-4cd4-8a76-6abbe906e5bb
1 parent 87f5480 commit c56fff0

File tree

5 files changed

+226
-10
lines changed

5 files changed

+226
-10
lines changed

src/components/custom/widget/WorkflowTemplateSelectorDialog.vue

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
</template>
1818

1919
<template #header>
20-
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
20+
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
2121
</template>
2222

2323
<template #header-right-area>
@@ -87,7 +87,7 @@
8787
v-model="sortBy"
8888
:label="$t('templateWorkflows.sorting', 'Sort by')"
8989
:options="sortOptions"
90-
class="min-w-[270px]"
90+
class="w-62.5"
9191
>
9292
<template #icon>
9393
<i class="icon-[lucide--arrow-up-down]" />
@@ -620,10 +620,7 @@ const sortOptions = computed(() => [
620620
value: 'default'
621621
},
622622
{
623-
name: t(
624-
'templateWorkflows.sort.vramLowToHigh',
625-
'VRAM Utilization (Low to High)'
626-
),
623+
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
627624
value: 'vram-low-to-high'
628625
},
629626
{
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { mount } from '@vue/test-utils'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { nextTick } from 'vue'
4+
import { createI18n } from 'vue-i18n'
5+
6+
import SearchBox from './SearchBox.vue'
7+
8+
const i18n = createI18n({
9+
legacy: false,
10+
locale: 'en',
11+
messages: {
12+
en: {
13+
templateWidgets: {
14+
sort: {
15+
searchPlaceholder: 'Search...'
16+
}
17+
}
18+
}
19+
}
20+
})
21+
22+
describe('SearchBox', () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks()
25+
vi.useFakeTimers()
26+
})
27+
28+
afterEach(() => {
29+
vi.restoreAllMocks()
30+
})
31+
32+
const createWrapper = (props = {}) => {
33+
return mount(SearchBox, {
34+
props: {
35+
modelValue: '',
36+
...props
37+
},
38+
global: {
39+
plugins: [i18n]
40+
}
41+
})
42+
}
43+
44+
describe('debounced search functionality', () => {
45+
it('should debounce search input by 300ms', async () => {
46+
const wrapper = createWrapper()
47+
const input = wrapper.find('input')
48+
49+
// Type search query
50+
await input.setValue('test')
51+
52+
// Model should not update immediately
53+
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
54+
55+
// Advance timers by 299ms (just before debounce delay)
56+
vi.advanceTimersByTime(299)
57+
await nextTick()
58+
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
59+
60+
// Advance timers by 1ms more (reaching 300ms)
61+
vi.advanceTimersByTime(1)
62+
await nextTick()
63+
64+
// Model should now be updated
65+
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
66+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test'])
67+
})
68+
69+
it('should reset debounce timer on each keystroke', async () => {
70+
const wrapper = createWrapper()
71+
const input = wrapper.find('input')
72+
73+
// Type first character
74+
await input.setValue('t')
75+
vi.advanceTimersByTime(200)
76+
await nextTick()
77+
78+
// Type second character (should reset timer)
79+
await input.setValue('te')
80+
vi.advanceTimersByTime(200)
81+
await nextTick()
82+
83+
// Type third character (should reset timer again)
84+
await input.setValue('tes')
85+
vi.advanceTimersByTime(200)
86+
await nextTick()
87+
88+
// Should not have emitted yet (only 200ms passed since last keystroke)
89+
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
90+
91+
// Advance final 100ms to reach 300ms
92+
vi.advanceTimersByTime(100)
93+
await nextTick()
94+
95+
// Should now emit with final value
96+
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
97+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['tes'])
98+
})
99+
100+
it('should only emit final value after rapid typing', async () => {
101+
const wrapper = createWrapper()
102+
const input = wrapper.find('input')
103+
104+
// Simulate rapid typing
105+
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
106+
for (const term of searchTerms) {
107+
await input.setValue(term)
108+
vi.advanceTimersByTime(50) // Less than debounce delay
109+
}
110+
111+
// Should not have emitted yet
112+
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
113+
114+
// Complete the debounce delay
115+
vi.advanceTimersByTime(300)
116+
await nextTick()
117+
118+
// Should emit only once with final value
119+
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
120+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['search'])
121+
})
122+
123+
describe('bidirectional model sync', () => {
124+
it('should sync external model changes to internal state', async () => {
125+
const wrapper = createWrapper({ modelValue: 'initial' })
126+
const input = wrapper.find('input')
127+
128+
expect(input.element.value).toBe('initial')
129+
130+
// Update model externally
131+
await wrapper.setProps({ modelValue: 'external update' })
132+
await nextTick()
133+
134+
// Internal state should sync
135+
expect(input.element.value).toBe('external update')
136+
})
137+
})
138+
139+
describe('placeholder', () => {
140+
it('should use custom placeholder when provided', () => {
141+
const wrapper = createWrapper({ placeholder: 'Custom search...' })
142+
const input = wrapper.find('input')
143+
144+
expect(input.attributes('placeholder')).toBe('Custom search...')
145+
expect(input.attributes('aria-label')).toBe('Custom search...')
146+
})
147+
148+
it('should use default placeholder when not provided', () => {
149+
const wrapper = createWrapper()
150+
const input = wrapper.find('input')
151+
152+
expect(input.attributes('placeholder')).toBe('Search...')
153+
expect(input.attributes('aria-label')).toBe('Search...')
154+
})
155+
})
156+
157+
describe('autofocus', () => {
158+
it('should focus input when autofocus is true', async () => {
159+
const wrapper = createWrapper({ autofocus: true })
160+
await nextTick()
161+
162+
const input = wrapper.find('input')
163+
const inputElement = input.element as HTMLInputElement
164+
165+
// Note: In JSDOM, focus() doesn't actually set document.activeElement
166+
// We can only verify that the focus method exists and doesn't throw
167+
expect(inputElement.focus).toBeDefined()
168+
})
169+
170+
it('should not autofocus when autofocus is false', () => {
171+
const wrapper = createWrapper({ autofocus: false })
172+
const input = wrapper.find('input')
173+
174+
expect(document.activeElement).not.toBe(input.element)
175+
})
176+
})
177+
178+
describe('click to focus', () => {
179+
it('should focus input when wrapper is clicked', async () => {
180+
const wrapper = createWrapper()
181+
const wrapperDiv = wrapper.find('[class*="flex"]')
182+
183+
await wrapperDiv.trigger('click')
184+
await nextTick()
185+
186+
// Input should receive focus
187+
const input = wrapper.find('input').element as HTMLInputElement
188+
expect(input.focus).toBeDefined()
189+
})
190+
})
191+
})
192+
})

src/components/input/SearchBox.vue

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<i class="icon-[lucide--search]" :class="iconColorStyle" />
44
<InputText
55
ref="input"
6-
v-model="searchQuery"
6+
v-model="internalSearchQuery"
77
:aria-label="
88
placeholder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
99
"
@@ -18,12 +18,15 @@
1818
</template>
1919

2020
<script setup lang="ts">
21+
import { useDebounceFn } from '@vueuse/core'
2122
import InputText from 'primevue/inputtext'
22-
import { computed, onMounted, ref } from 'vue'
23+
import { computed, onMounted, ref, watch } from 'vue'
2324
2425
import { t } from '@/i18n'
2526
import { cn } from '@/utils/tailwindUtil'
2627
28+
const SEARCH_DEBOUNCE_DELAY_MS = 300
29+
2730
const {
2831
autofocus = false,
2932
placeholder,
@@ -35,9 +38,30 @@ const {
3538
showBorder?: boolean
3639
size?: 'md' | 'lg'
3740
}>()
41+
3842
// defineModel without arguments uses 'modelValue' as the prop name
3943
const searchQuery = defineModel<string>()
4044
45+
// Internal search query state for immediate UI updates
46+
const internalSearchQuery = ref<string>(searchQuery.value ?? '')
47+
48+
// Create debounced function to update the parent model
49+
const updateSearchQuery = useDebounceFn((value: string) => {
50+
searchQuery.value = value
51+
}, SEARCH_DEBOUNCE_DELAY_MS)
52+
53+
// Watch internal query changes and trigger debounced update
54+
watch(internalSearchQuery, (newValue) => {
55+
void updateSearchQuery(newValue)
56+
})
57+
58+
// Sync external changes back to internal state
59+
watch(searchQuery, (newValue) => {
60+
if (newValue !== internalSearchQuery.value) {
61+
internalSearchQuery.value = newValue || ''
62+
}
63+
})
64+
4165
const input = ref<{ $el: HTMLElement } | null>()
4266
const focusInput = () => {
4367
if (input.value && input.value.$el) {

src/components/widget/nav/NavItem.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
class="flex items-center gap-2 px-4 py-3 text-sm rounded-md transition-colors cursor-pointer"
44
:class="
55
active
6-
? 'bg-white dark-theme:bg-charcoal-600 text-neutral'
6+
? 'bg-gray-400 dark-theme:bg-charcoal-300 text-neutral'
77
: 'text-neutral hover:bg-gray-100 dark-theme:hover:bg-charcoal-300'
88
"
99
role="button"

src/locales/en/main.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1029,7 +1029,10 @@
10291029
"recommended": "Recommended",
10301030
"alphabetical": "A → Z",
10311031
"newest": "Newest",
1032-
"searchPlaceholder": "Search..."
1032+
"searchPlaceholder": "Search...",
1033+
"vramLowToHigh": "VRAM Usage (Low to High)",
1034+
"modelSizeLowToHigh": "Model Size (Low to High)",
1035+
"default": "Default"
10331036
}
10341037
},
10351038
"graphCanvasMenu": {

0 commit comments

Comments
 (0)