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`] = `
+"
"
+`;
+
+exports[`should hide items not matching search value > string items 1`] = `
+""
+`;
+
+exports[`should render with object items 1`] = `
+""
+`;
+
+exports[`should render with string items 1`] = `
+""
+`;
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)