Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions resources/js/components/inputs/relationship/SelectField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
:read-only="readOnly"
:taggable="isTaggable"
:close-on-select="isTaggable"
:search-keys="searchKeys"
option-label="title"
option-value="id"
@update:modelValue="itemsSelected"
Expand Down Expand Up @@ -91,6 +92,12 @@ export default {
};
},

// The `users` fieldtype falls back to displaying a user's email as their title when
// they have no name, but doesn't show it otherwise, so it needs to be searchable too.
searchKeys() {
return this.config.type === 'users' ? ['title', 'email'] : null;
},

cacheKey() {
return JSON.stringify({ ...this.parameters, url: this.url });
},
Expand Down
4 changes: 3 additions & 1 deletion resources/js/components/ui/Combobox/Combobox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ const props = defineProps({
readOnly: { type: Boolean, default: false },
/** When `true`, the options will be searchable. */
searchable: { type: Boolean, default: true },
/** Keys of the option object to search against. Defaults to just `optionLabel`. */
searchKeys: { type: Array, default: null },
/** Determines if the dropdown should open */
shouldOpenDropdown: { type: Function, default: () => true },
/** Controls the size of the combobox. <br><br> Options: `xs`, `sm`, `base`, `lg`, `xl` */
Expand Down Expand Up @@ -236,7 +238,7 @@ const filteredOptions = computed(() => {
fuzzysort
.go(searchQuery.value, props.options, {
all: true,
key: props.optionLabel,
...(props.searchKeys?.length ? { keys: props.searchKeys } : { key: props.optionLabel }),
})
.map((result) => result.obj)
);
Expand Down
78 changes: 78 additions & 0 deletions resources/js/stories/Combobox.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,50 @@ export const _IgnoreFilter: Story = {
}),
};

const searchKeysCode = `
<Combobox
placeholder="Select author..."
:search-keys="['label', 'email']"
:options="[
{ label: 'Tyler Lyle', email: 'workhorse92@example.com', value: 'tyler' },
{ label: 'Tim McEwan', email: 'nightowl47@example.com', value: 'tim' },
{ label: 'Nikki Flores', email: 'skyline08@example.com', value: 'nikki' },
]"
/>
`;

export const _SearchKeys: Story = {
tags: ['!dev'],
parameters: {
docs: {
source: { code: searchKeysCode },
description: {
story: 'By default, search only matches against `optionLabel`. Use `searchKeys` to also match against other keys on the option object — useful when an option has a value that isn\'t displayed but should still be searchable, such as an email address. Try searching "nightowl" below — it only appears in Tim McEwan\'s email, not his label.',
},
},
},
render: () => ({
components: { Combobox },
setup() {
const value = ref(null);
const options = [
{ label: 'Tyler Lyle', email: 'workhorse92@example.com', value: 'tyler' },
{ label: 'Tim McEwan', email: 'nightowl47@example.com', value: 'tim' },
{ label: 'Nikki Flores', email: 'skyline08@example.com', value: 'nikki' },
];
return { value, options };
},
template: `
<Combobox
v-model="value"
placeholder="Select author..."
:search-keys="['label', 'email']"
:options="options"
/>
`,
}),
};

const optionSlotsCode = `
<Combobox
placeholder="Select author..."
Expand Down Expand Up @@ -727,6 +771,40 @@ export const TestCanSearchOptions: Story = {
},
};

export const TestSearchKeysMatchesAdditionalFields: Story = {
tags: ['!dev', 'test'],
render: () => ({
components: { Combobox },
setup() {
const value = ref(null);
const options = [
{ label: 'Tyler Lyle', email: 'workhorse92@example.com', value: 'tyler' },
{ label: 'Tim McEwan', email: 'nightowl47@example.com', value: 'tim' },
{ label: 'Nikki Flores', email: 'skyline08@example.com', value: 'nikki' },
];
return { value, options };
},
template: `<Combobox v-model="value" :options="options" :search-keys="['label', 'email']" placeholder="Select..." />`,
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole('combobox');

await userEvent.click(trigger);

const input = document.querySelector('input[type="search"]') as HTMLInputElement;

// "nightowl" only appears in Tim McEwan's email, not in any label.
await userEvent.type(input, 'nightowl');

await new Promise((r) => setTimeout(r, 100));

const options = document.querySelectorAll('[data-ui-combobox-item]');
expect(options.length).toBe(1);
expect(options[0].getAttribute('data-ui-combobox-item')).toBe('tim');
},
};

export const TestSearchDisabledWhenNotSearchable: Story = {
tags: ['!dev', 'test'],
render: () => ({
Expand Down
4 changes: 4 additions & 0 deletions resources/js/stories/docs/Combobox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ By default, the combobox is searchable. You may disable search by setting the `s
When `ignoreFilter` is `true`, the combobox won't filter options locally. Use this with the `search` event to implement server-side searching.
<Canvas of={ComboboxStories._IgnoreFilter} sourceState={'shown'} />

## Search by Multiple Keys
By default, search only matches against the `optionLabel` key. Use the `searchKeys` prop to also match against other keys on the option object — useful when an option has a value that isn't displayed but should still be searchable, such as an email address.
<Canvas of={ComboboxStories._SearchKeys} sourceState={'shown'} />

## Customize Options
You may customize how options are rendered using the `selected-option` and `option` slots.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ const stubs = {
StatusIndicator: true,
};

function mountSelectField({ items = [] } = {}) {
function mountSelectField({ items = [], config = {} } = {}) {
return mount(SelectField, {
props: {
items,
url: '/test/select-field',
config: {},
config,
},
global: {
mocks: {
Expand Down Expand Up @@ -51,3 +51,21 @@ describe('SelectField comboboxOptions', () => {
wrapper.unmount();
});
});

describe('SelectField searchKeys', () => {
test('searches by title and email for the users fieldtype', () => {
const wrapper = mountSelectField({ config: { type: 'users' } });

expect(wrapper.vm.searchKeys).toEqual(['title', 'email']);

wrapper.unmount();
});

test('is null for other relationship fieldtypes', () => {
const wrapper = mountSelectField({ config: { type: 'entries' } });

expect(wrapper.vm.searchKeys).toBeNull();

wrapper.unmount();
});
});
Loading