From 74ba9f261fbaec733f27407cbd4f9b8cd3326bd3 Mon Sep 17 00:00:00 2001 From: Pascal Jufer Date: Tue, 26 Sep 2023 15:40:20 +0200 Subject: [PATCH] Enable search in `v-select` for string items (#19736) Co-authored-by: Rijk van Zanten --- .changeset/chatty-cheetahs-begin.md | 5 + .../__snapshots__/v-select.test.ts.snap | 168 +++++++++++++++++- app/src/components/v-select/v-select.test.ts | 145 +++++++++++---- app/src/components/v-select/v-select.vue | 48 ++--- 4 files changed, 311 insertions(+), 55 deletions(-) create mode 100644 .changeset/chatty-cheetahs-begin.md diff --git a/.changeset/chatty-cheetahs-begin.md b/.changeset/chatty-cheetahs-begin.md new file mode 100644 index 0000000000..e7d867b8ec --- /dev/null +++ b/.changeset/chatty-cheetahs-begin.md @@ -0,0 +1,5 @@ +--- +"@directus/app": patch +--- + +Enabled search in `v-select` component for string items diff --git a/app/src/components/v-select/__snapshots__/v-select.test.ts.snap b/app/src/components/v-select/__snapshots__/v-select.test.ts.snap index dd5b4e7c80..d9fa3f2369 100644 --- a/app/src/components/v-select/__snapshots__/v-select.test.ts.snap +++ b/app/src/components/v-select/__snapshots__/v-select.test.ts.snap @@ -1,3 +1,169 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Mount component 1`] = `""`; +exports[`should hide items not matching search value > object items 1`] = ` +"
+
+
    + +
    +
    +
    +
    +
    +
    + +
    Item 1
    +
    +
    + +
    Item 2
    +
    +
    + +
    Item 3
    +
    +
    + +
    Item 4
    +
    +
    + +
    Item 5
    +
    +
    + +
    Item 6
    +
    +
    + +
    Item 7
    +
    +
    + +
    Item 8
    +
    +
    + +
    Item 9
    +
    +
    + +
    Item 10
    +
    +
    + +
    Item 11
    +
    + + +
+
" +`; + +exports[`should hide items not matching search value > string items 1`] = ` +"
+
+
    + +
    +
    +
    +
    +
    +
    + +
    Item 1
    +
    +
    + +
    Item 2
    +
    +
    + +
    Item 3
    +
    +
    + +
    Item 4
    +
    +
    + +
    Item 5
    +
    +
    + +
    Item 6
    +
    +
    + +
    Item 7
    +
    +
    + +
    Item 8
    +
    +
    + +
    Item 9
    +
    +
    + +
    Item 10
    +
    +
    + +
    Item 11
    +
    + + +
+
" +`; + +exports[`should render with object items 1`] = ` +"
+ +
    + + +
    + +
    Item 1
    +
    +
    + +
    Item 2
    +
    +
    + +
    Item 3
    +
    + + +
+
" +`; + +exports[`should render with string items 1`] = ` +"
+ +
    + + +
    + +
    Item 1
    +
    +
    + +
    Item 2
    +
    +
    + +
    Item 3
    +
    + + +
+
" +`; diff --git a/app/src/components/v-select/v-select.test.ts b/app/src/components/v-select/v-select.test.ts index b2a9617bda..20a3f3e069 100644 --- a/app/src/components/v-select/v-select.test.ts +++ b/app/src/components/v-select/v-select.test.ts @@ -1,47 +1,79 @@ -import { test, expect } from 'vitest'; -import { mount } from '@vue/test-utils'; - -import VSelect from './v-select.vue'; -import { GlobalMountOptions } from '@vue/test-utils/dist/types'; -import { createI18n } from 'vue-i18n'; import { Focus } from '@/__utils__/focus'; +import { mount } from '@vue/test-utils'; +import { describe, expect, test } from 'vitest'; +import { createI18n } from 'vue-i18n'; +import VList from '../v-list.vue'; +import VSelect from './v-select.vue'; -const i18n = createI18n({ legacy: false }); +const i18n = createI18n({ + legacy: false, + messages: { + 'en-US': { + search: 'search', + }, + }, +}); -const global: GlobalMountOptions = { - stubs: [ - 'v-list', - 'v-list-item', - 'v-list-item-icon', - 'v-list-item-content', - 'v-divider', - 'v-checkbox', - 'v-menu', - 'v-icon', - 'v-input', - ], +const VMenu = { + template: ` +
+ + +
+ `, +}; + +const VInput = { + template: `
`, + setup(_props: any, { emit }: any) { + emit('update:modelValue', 'Item 1'); + }, +}; + +const VListItem = { + template: ` +
+ +
+ `, +}; + +const VListItemContent = { + template: ` +
+ +
+ `, +}; + +const global = { + stubs: { + 'v-menu': VMenu, + 'v-input': true, + 'v-list': VList, + 'v-list-item': VListItem, + 'v-list-item-content': VListItemContent, + 'v-list-item-icon': true, + 'v-divider': true, + 'v-checkbox': true, + 'v-icon': true, + }, plugins: [i18n], directives: { Focus, }, }; -const items = [ - { - text: 'Item 1', - value: 'item1', - }, - { - text: 'Item 2', - value: 'item2', - }, - { - text: 'Item 3', - value: 'item3', - }, -]; +test('should render with object items', () => { + const items = Array.from({ length: 3 }, (_, index) => { + const number = index + 1; + return { text: `Item ${number}`, value: `item${number}` }; + }); -test('Mount component', () => { expect(VSelect).toBeTruthy(); const wrapper = mount(VSelect, { @@ -53,3 +85,48 @@ test('Mount component', () => { expect(wrapper.html()).toMatchSnapshot(); }); + +test('should render with string items', () => { + const items = Array.from({ length: 3 }, (_, index) => `Item ${index + 1}`); + + expect(VSelect).toBeTruthy(); + + const wrapper = mount(VSelect, { + props: { + items, + }, + global, + }); + + expect(wrapper.html()).toMatchSnapshot(); +}); + +describe('should hide items not matching search value', () => { + // There have to be >10 items to enable search + + const objectItems = Array.from({ length: 11 }, (_, index) => { + const number = index + 1; + return { text: `Item ${number}`, value: `item${number}` }; + }); + + const stringItems = Array.from({ length: 11 }, (_, index) => `Item ${index + 1}`); + + test.each([ + ['object items', objectItems], + ['string items', stringItems], + ])('%s', async (_, items) => { + expect(VSelect).toBeTruthy(); + + const wrapper = mount(VSelect, { + props: { + items, + }, + global: { ...global, stubs: { ...global.stubs, 'v-input': VInput } }, + }); + + // Wait for search debounce + await new Promise((r) => setTimeout(r, 300)); + + expect(wrapper.html()).toMatchSnapshot(); + }); +}); diff --git a/app/src/components/v-select/v-select.vue b/app/src/components/v-select/v-select.vue index 853009cd71..8fd84f091c 100644 --- a/app/src/components/v-select/v-select.vue +++ b/app/src/components/v-select/v-select.vue @@ -204,17 +204,10 @@ const props = withDefaults(defineProps(), { itemSelectable: 'selectable', itemChildren: 'children', modelValue: null, - multiple: false, - groupSelectable: false, mandatory: true, placeholder: null, fullWidth: true, - disabled: false, - showDeselect: false, - allowOther: false, closeOnContentClick: true, - inline: false, - label: false, multiplePreviewThreshold: 3, placement: 'bottom', isMenuSameWidth: true, @@ -251,42 +244,57 @@ function useItems() { const internalSearch = ref(null); const internalItems = computed(() => { - const parseItem = (item: Record): Option => { + const parseItem = (item: Record | string): Option => { if (typeof item === 'string') { return { text: item, value: item, + hidden: internalSearch.value ? !filterItem(item) : false, }; } if (item.divider === true) return { value: null, divider: true }; + const text = get(item, props.itemText); + const value = get(item, props.itemValue); const children = get(item, props.itemChildren) ? get(item, props.itemChildren).map(parseItem) : null; return { - text: get(item, props.itemText), - value: get(item, props.itemValue), + text, + value, icon: props.itemIcon ? get(item, props.itemIcon) : undefined, disabled: get(item, props.itemDisabled), selectable: get(item, props.itemSelectable), - children: children ? children.filter(filterItem) : children, - hidden: internalSearch.value ? !filterItem(item) : false, + children: children + ? children.filter((childItem: Record) => + filterItem(get(childItem, props.itemText), get(childItem, props.itemValue), childItem.children) + ) + : children, + hidden: internalSearch.value ? !filterItem(text, value, item.children) : false, }; }; - const filterItem = (item: Record): boolean => { + const filterItem = ( + text: string | undefined, + value?: string | number | null, + children?: Record[] | null + ): boolean => { if (!internalSearch.value) return true; const searchValue = internalSearch.value.toLowerCase(); - return item?.children - ? isMatchingCurrentItem(item, searchValue) || - item.children.some((item: Record) => filterItem(item)) - : isMatchingCurrentItem(item, searchValue); + return children + ? isMatchingCurrentItem(text, value, searchValue) || + children.some((childItem: Record) => + filterItem(get(childItem, props.itemText), get(childItem, props.itemValue), childItem.children) + ) + : isMatchingCurrentItem(text, value, searchValue); - function isMatchingCurrentItem(item: Record, searchValue: string): boolean { - const text = get(item, props.itemText); - const value = get(item, props.itemValue); + function isMatchingCurrentItem( + text: string | undefined, + value: string | number | null | undefined, + searchValue: string + ): boolean { return ( (text ? String(text).toLowerCase().includes(searchValue) : false) || (value ? String(value).toLowerCase().includes(searchValue) : false)