From c56fff0b8b152d012564e595b767f299b5212b3c Mon Sep 17 00:00:00 2001
From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Date: Wed, 8 Oct 2025 22:06:03 +0100
Subject: [PATCH] Workflow templates review (#5975)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../widget/WorkflowTemplateSelectorDialog.vue | 9 +-
src/components/input/SearchBox.test.ts | 192 ++++++++++++++++++
src/components/input/SearchBox.vue | 28 ++-
src/components/widget/nav/NavItem.vue | 2 +-
src/locales/en/main.json | 5 +-
5 files changed, 226 insertions(+), 10 deletions(-)
create mode 100644 src/components/input/SearchBox.test.ts
diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
index 9cc35d65a..908366cb2 100644
--- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
+++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
@@ -17,7 +17,7 @@
-
+
@@ -87,7 +87,7 @@
v-model="sortBy"
:label="$t('templateWorkflows.sorting', 'Sort by')"
:options="sortOptions"
- class="min-w-[270px]"
+ class="w-62.5"
>
@@ -620,10 +620,7 @@ const sortOptions = computed(() => [
value: 'default'
},
{
- name: t(
- 'templateWorkflows.sort.vramLowToHigh',
- 'VRAM Utilization (Low to High)'
- ),
+ name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
value: 'vram-low-to-high'
},
{
diff --git a/src/components/input/SearchBox.test.ts b/src/components/input/SearchBox.test.ts
new file mode 100644
index 000000000..575cbb0f5
--- /dev/null
+++ b/src/components/input/SearchBox.test.ts
@@ -0,0 +1,192 @@
+import { mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+import { createI18n } from 'vue-i18n'
+
+import SearchBox from './SearchBox.vue'
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ templateWidgets: {
+ sort: {
+ searchPlaceholder: 'Search...'
+ }
+ }
+ }
+ }
+})
+
+describe('SearchBox', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ const createWrapper = (props = {}) => {
+ return mount(SearchBox, {
+ props: {
+ modelValue: '',
+ ...props
+ },
+ global: {
+ plugins: [i18n]
+ }
+ })
+ }
+
+ describe('debounced search functionality', () => {
+ it('should debounce search input by 300ms', async () => {
+ const wrapper = createWrapper()
+ const input = wrapper.find('input')
+
+ // Type search query
+ await input.setValue('test')
+
+ // Model should not update immediately
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
+
+ // Advance timers by 299ms (just before debounce delay)
+ vi.advanceTimersByTime(299)
+ await nextTick()
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
+
+ // Advance timers by 1ms more (reaching 300ms)
+ vi.advanceTimersByTime(1)
+ await nextTick()
+
+ // Model should now be updated
+ expect(wrapper.emitted('update:modelValue')).toBeTruthy()
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test'])
+ })
+
+ it('should reset debounce timer on each keystroke', async () => {
+ const wrapper = createWrapper()
+ const input = wrapper.find('input')
+
+ // Type first character
+ await input.setValue('t')
+ vi.advanceTimersByTime(200)
+ await nextTick()
+
+ // Type second character (should reset timer)
+ await input.setValue('te')
+ vi.advanceTimersByTime(200)
+ await nextTick()
+
+ // Type third character (should reset timer again)
+ await input.setValue('tes')
+ vi.advanceTimersByTime(200)
+ await nextTick()
+
+ // Should not have emitted yet (only 200ms passed since last keystroke)
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
+
+ // Advance final 100ms to reach 300ms
+ vi.advanceTimersByTime(100)
+ await nextTick()
+
+ // Should now emit with final value
+ expect(wrapper.emitted('update:modelValue')).toBeTruthy()
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['tes'])
+ })
+
+ it('should only emit final value after rapid typing', async () => {
+ const wrapper = createWrapper()
+ const input = wrapper.find('input')
+
+ // Simulate rapid typing
+ const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
+ for (const term of searchTerms) {
+ await input.setValue(term)
+ vi.advanceTimersByTime(50) // Less than debounce delay
+ }
+
+ // Should not have emitted yet
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
+
+ // Complete the debounce delay
+ vi.advanceTimersByTime(300)
+ await nextTick()
+
+ // Should emit only once with final value
+ expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['search'])
+ })
+
+ describe('bidirectional model sync', () => {
+ it('should sync external model changes to internal state', async () => {
+ const wrapper = createWrapper({ modelValue: 'initial' })
+ const input = wrapper.find('input')
+
+ expect(input.element.value).toBe('initial')
+
+ // Update model externally
+ await wrapper.setProps({ modelValue: 'external update' })
+ await nextTick()
+
+ // Internal state should sync
+ expect(input.element.value).toBe('external update')
+ })
+ })
+
+ describe('placeholder', () => {
+ it('should use custom placeholder when provided', () => {
+ const wrapper = createWrapper({ placeholder: 'Custom search...' })
+ const input = wrapper.find('input')
+
+ expect(input.attributes('placeholder')).toBe('Custom search...')
+ expect(input.attributes('aria-label')).toBe('Custom search...')
+ })
+
+ it('should use default placeholder when not provided', () => {
+ const wrapper = createWrapper()
+ const input = wrapper.find('input')
+
+ expect(input.attributes('placeholder')).toBe('Search...')
+ expect(input.attributes('aria-label')).toBe('Search...')
+ })
+ })
+
+ describe('autofocus', () => {
+ it('should focus input when autofocus is true', async () => {
+ const wrapper = createWrapper({ autofocus: true })
+ await nextTick()
+
+ const input = wrapper.find('input')
+ const inputElement = input.element as HTMLInputElement
+
+ // Note: In JSDOM, focus() doesn't actually set document.activeElement
+ // We can only verify that the focus method exists and doesn't throw
+ expect(inputElement.focus).toBeDefined()
+ })
+
+ it('should not autofocus when autofocus is false', () => {
+ const wrapper = createWrapper({ autofocus: false })
+ const input = wrapper.find('input')
+
+ expect(document.activeElement).not.toBe(input.element)
+ })
+ })
+
+ describe('click to focus', () => {
+ it('should focus input when wrapper is clicked', async () => {
+ const wrapper = createWrapper()
+ const wrapperDiv = wrapper.find('[class*="flex"]')
+
+ await wrapperDiv.trigger('click')
+ await nextTick()
+
+ // Input should receive focus
+ const input = wrapper.find('input').element as HTMLInputElement
+ expect(input.focus).toBeDefined()
+ })
+ })
+ })
+})
diff --git a/src/components/input/SearchBox.vue b/src/components/input/SearchBox.vue
index 4566b6f1a..8cc5e5f36 100644
--- a/src/components/input/SearchBox.vue
+++ b/src/components/input/SearchBox.vue
@@ -3,7 +3,7 @@