diff --git a/apps/client/src/views/editor/finder/Finder.module.scss b/apps/client/src/views/editor/finder/Finder.module.scss index 99c2a318e..094aa2106 100644 --- a/apps/client/src/views/editor/finder/Finder.module.scss +++ b/apps/client/src/views/editor/finder/Finder.module.scss @@ -3,11 +3,14 @@ .error { padding-inline: 0.5rem; font-size: 1rem; - height: 3rem; + // rows grow when a match is shown from a note or custom field + min-height: 3rem; + padding-block: 0.35rem; display: flex; align-items: center; justify-content: space-between; + gap: 0.5rem; } .entry[data-selected='true'] { @@ -22,13 +25,49 @@ color: $error-red; } +.filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + padding-top: 0.75rem; +} + +.filterLabel { + font-size: calc(1rem - 3px); + color: $label-gray; + margin-right: 0.15rem; +} + +.filterBadge { + font-size: calc(1rem - 3px); + line-height: 1; + color: $ui-white; + background-color: $gray-1000; + border: 1px solid $gray-900; + border-radius: 3px; + padding: 0.3rem 0.5rem; + cursor: pointer; + + &:hover { + background-color: $gray-900; + } + + &:focus-visible { + outline: 2px solid $blue-700; + outline-offset: 1px; + } +} + .data { display: grid; grid-template-areas: 'index cue' - 'index title'; + 'index title' + 'index match'; column-gap: 1rem; grid-template-rows: min-content 1fr; + min-width: 0; .index { grid-area: index; @@ -42,14 +81,33 @@ .title { grid-area: title; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .cue { grid-area: cue; font-size: calc(1rem - 2px); color: $label-gray; - max-height: 1em; min-height: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .match { + grid-area: match; + font-size: calc(1rem - 3px); + color: $label-gray; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .matchLabel { + color: $ui-white; + margin-right: 0.4rem; } } @@ -63,13 +121,14 @@ color: $label-gray; } -.filterHint { +.count { text-align: right; + white-space: nowrap; } -.em { - color: $ui-white; - margin-inline: 0.25rem; +.go { + white-space: nowrap; + padding-left: 1rem; } .hints { @@ -98,7 +157,7 @@ flex-direction: column; } - .filterHint { + .count { text-align: left; } } diff --git a/apps/client/src/views/editor/finder/Finder.tsx b/apps/client/src/views/editor/finder/Finder.tsx index 606d63969..f01046540 100644 --- a/apps/client/src/views/editor/finder/Finder.tsx +++ b/apps/client/src/views/editor/finder/Finder.tsx @@ -1,11 +1,10 @@ -import { useDebouncedCallback } from '@mantine/hooks'; import { EntryId, MaybeString, SupportedEntry } from 'ontime-types'; -import { KeyboardEvent, PointerEvent, useEffect, useRef, useState } from 'react'; +import { KeyboardEvent, PointerEvent, useDeferredValue, useEffect, useRef, useState } from 'react'; import Input from '../../../common/components/input/input/Input'; import Kbd from '../../../common/components/kbd/Kbd'; import Modal from '../../../common/components/modal/Modal'; -import useFinder, { FilterableEntry } from './useFinder'; +import useFinder, { applyFilter, FilterableEntry } from './useFinder'; import style from './Finder.module.scss'; @@ -15,14 +14,20 @@ interface FinderProps { } export default function Finder({ isOpen, onClose }: FinderProps) { - const { find, select, results, error } = useFinder(); + const [search, setSearch] = useState(''); const [selectedId, setSelectedId] = useState(null); + /** + * Keeps typing responsive while the list re-renders. + * The search itself is cheap, rendering the results is what costs. + */ + const deferredSearch = useDeferredValue(search); + const { select, results, error, total, filters } = useFinder(deferredSearch); + + const inputRef = useRef(null); const activeRef = useRef(null); const lastPointer = useRef({ x: -1, y: -1 }); - const debouncedFind = useDebouncedCallback(find, 100); - /** * We track the selection by ID so that it survives the result list changing under us: * an entry that no longer exists falls back to the first result instead of dangling past the end @@ -74,6 +79,14 @@ export default function Finder({ isOpen, onClose }: FinderProps) { setSelectedId(id); }; + /** Scopes the search to a single field, keeping whatever the user already typed */ + const handleFilter = (filterKey: string) => { + setSearch(applyFilter(search, filters, filterKey)); + inputRef.current?.focus(); + }; + + const hasOverflow = total > results.length; + return ( - + setSearch(event.target.value)} + placeholder='Search...' + /> +
+ Filter by + {filters.map((filter) => ( + + ))} +
    {error &&
  • {error}
  • } - {results.length === 0 &&
  • No results
  • } - {results.length > 0 && - results.map((entry) => { - const isSelected = activeEntry?.id === entry.id; - const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-'; - const displayCue = 'cue' in entry ? entry.cue : ''; + {!error && results.length === 0 &&
  • No results
  • } + {results.map((entry) => { + const isSelected = activeEntry?.id === entry.id; + const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-'; + const displayCue = 'cue' in entry ? entry.cue : ''; + // the title and cue are already on the row, anything else needs showing + const showMatch = entry.match !== null && entry.match.label !== 'Title' && entry.match.label !== 'Cue'; - return ( -
  • submit(entry)} - onPointerMove={(event) => handlePointerMove(event, entry.id)} - > -
    -
    - {displayIndex} -
    -
    {displayCue}
    -
    {entry.title}
    + return ( +
  • submit(entry)} + onPointerMove={(event) => handlePointerMove(event, entry.id)} + > +
    +
    + {displayIndex}
    - {isSelected && Go ⏎} -
  • - ); - })} +
    {displayCue}
    +
    {entry.title}
    + {showMatch && ( +
    + {entry.match?.label} + {entry.match?.excerpt} +
    + )} + + {isSelected && Go ⏎} + + ); + })}
} @@ -133,10 +174,11 @@ export default function Finder({ isOpen, onClose }: FinderProps) { Close -
- Filter by cue, index, or - title -
+ {total > 0 && ( +
+ {hasOverflow ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`} +
+ )} } /> diff --git a/apps/client/src/views/editor/finder/useFinder.tsx b/apps/client/src/views/editor/finder/useFinder.tsx index b996e4bfe..9ceba2af2 100644 --- a/apps/client/src/views/editor/finder/useFinder.tsx +++ b/apps/client/src/views/editor/finder/useFinder.tsx @@ -1,228 +1,282 @@ -import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; -import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { + CustomFields, + EntryId, + MaybeString, + OntimeEntry, + SupportedEntry, + isOntimeEvent, + isOntimeGroup, + isOntimeMilestone, +} from 'ontime-types'; +import { useCallback, useMemo } from 'react'; +import useCustomFields from '../../../common/hooks-query/useCustomFields'; import { useFlatRundown } from '../../../common/hooks-query/useRundown'; import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry'; -const maxResults = 12; +/** How many results we render, the total number of matches is reported separately */ +const maxResults = 50; +/** Notes can hold a whole script, we only show enough to explain the match */ +const excerptPadding = 40; -type FilterableGroup = { - type: SupportedEntry.Group; +const indexFilter = 'index'; + +/** + * A field the user can scope a search to. + * Custom fields are appended to these at runtime. + */ +const staticFilters = [ + { key: indexFilter, label: 'Index' }, + { key: 'cue', label: 'Cue' }, + { key: 'title', label: 'Title' }, + { key: 'note', label: 'Note' }, +] as const; + +export type FinderFilter = { key: string; label: string }; + +/** Describes why an entry matched, so the UI can show the user */ +export type FinderMatch = { label: string; excerpt: string }; + +type FilterableBase = { id: EntryId; index: number; title: string; colour: string; + match: FinderMatch | null; }; -type FilterableEvent = { +type FilterableGroup = FilterableBase & { type: SupportedEntry.Group }; +type FilterableEvent = FilterableBase & { type: SupportedEntry.Event; - id: EntryId; - index: number; eventIndex: number; - title: string; cue: string; - colour: string; parent: MaybeString; }; - -type FilterableMilestone = { +type FilterableMilestone = FilterableBase & { type: SupportedEntry.Milestone; - id: EntryId; - index: number; - title: string; cue: string; - colour: string; parent: MaybeString; }; export type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone; -export default function useFinder() { +type SearchableField = { key: string; label: string; value: string }; + +/** + * Collects the text fields of an entry, in the order we prefer to report a match. + * Image custom fields hold a URL, which nobody searches for. + */ +function getSearchableFields(entry: OntimeEntry, customFields: CustomFields): SearchableField[] { + const fields: SearchableField[] = []; + + if ('cue' in entry && entry.cue) { + fields.push({ key: 'cue', label: 'Cue', value: entry.cue }); + } + if ('title' in entry && entry.title) { + fields.push({ key: 'title', label: 'Title', value: entry.title }); + } + if ('note' in entry && entry.note) { + fields.push({ key: 'note', label: 'Note', value: entry.note }); + } + if ('custom' in entry) { + for (const [key, value] of Object.entries(entry.custom)) { + const definition = customFields[key]; + if (!value || definition?.type !== 'text') { + continue; + } + fields.push({ key, label: definition.label || key, value }); + } + } + + return fields; +} + +/** Shows enough of a long value for the user to see why it matched */ +function makeExcerpt(value: string, matchIndex: number, searchLength: number): string { + const start = Math.max(0, matchIndex - excerptPadding); + const end = Math.min(value.length, matchIndex + searchLength + excerptPadding); + return `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`; +} + +/** + * Splits the raw search value into an optional field filter and the text to look for. + * Both `cue 12` and `cue:12` are accepted so the filter badges and typing agree. + */ +function parseQuery(searchValue: string, filters: FinderFilter[]) { + for (const filter of filters) { + // the value is already lowercased, custom field keys are not + const prefix = filter.key.toLowerCase(); + if (searchValue === prefix) { + // the filter is selected but nothing to search for yet + return { filterKey: filter.key, searchString: '' }; + } + if (searchValue.startsWith(`${prefix} `) || searchValue.startsWith(`${prefix}:`)) { + return { filterKey: filter.key, searchString: searchValue.slice(prefix.length + 1).trim() }; + } + } + return { filterKey: null, searchString: searchValue }; +} + +export default function useFinder(searchValue: string) { const { data, rundownId } = useFlatRundown(); - const [results, setResults] = useState([]); - const [error, setError] = useState(null); - const lastSearchString = useRef(''); + const { data: customFields } = useCustomFields(); const selectAndRevealEntry = useSelectAndRevealEntry(rundownId); - /** Filters the rundown to a given evaluation */ - const find = useCallback( - (event: ChangeEvent) => { - if (!data || data.length === 0) { - setError('No data'); - return; - } - setError(null); + /** The filters offered to the user: fixed fields plus whatever the project defines */ + const filters = useMemo(() => { + const customFilters = Object.entries(customFields) + .filter(([_key, field]) => field.type === 'text') + .map(([key, field]) => ({ key, label: field.label || key })); + return [...staticFilters, ...customFilters]; + }, [customFields]); - if (event.target.value === '') { - setResults([]); - return; + const { results, error, total } = useMemo(() => { + const empty = { results: [] as FilterableEntry[], error: null, total: 0 }; + + if (!data || data.length === 0) { + return { ...empty, error: 'No data' }; + } + + const normalised = searchValue.trim().toLowerCase(); + if (normalised === '') { + return empty; + } + + const { filterKey, searchString } = parseQuery(normalised, filters); + + if (filterKey === indexFilter) { + return searchByIndex(searchString); + } + + if (searchString === '') { + // a filter is selected, but there is nothing to match on yet + return empty; + } + + return searchByField(filterKey, searchString); + + /** Returns the single event at a given 1-based index */ + function searchByIndex(indexString: string) { + const searchIndex = Number(indexString); + if (isNaN(searchIndex) || searchIndex < 1) { + return { ...empty, error: 'Invalid index' }; } - const searchValue = event.target.value.toLowerCase(); - lastSearchString.current = searchValue; - - if (searchValue.startsWith('index ')) { - const searchString = searchValue.slice('index '.length).trim(); - const { results, error } = searchByIndex(searchString); - setResults(results); - setError(error); - return; - } - - if (searchValue.startsWith('cue ')) { - const searchString = searchValue.slice('cue '.length).trim(); - const { results, error } = searchByCue(searchString); - setResults(results); - setError(error); - return; - } - - const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue; - const { results, error } = searchByTitle(searchString); - setResults(results); - setError(error); - - /** Returns a single item with a matching index */ - function searchByIndex(searchString: string) { - const searchIndex = Number(searchString); - if (isNaN(searchIndex) || searchIndex < 1) { - return { results: [], error: 'Invalid index' }; + let eventIndex = 1; + for (let i = 0; i < data.length; i++) { + const entry = data[i]; + if (!isOntimeEvent(entry)) { + continue; } - - // indexes exposed to the UI are 1-based - let eventIndex = 1; - const results: FilterableEvent[] = []; - for (let i = 0; i < data.length; i++) { - const event = data[i]; - if (isOntimeEvent(event)) { - if (eventIndex === searchIndex) { - results.push({ + if (eventIndex === searchIndex) { + return { + error: null, + total: 1, + results: [ + { type: SupportedEntry.Event, - id: event.id, + id: entry.id, index: i, eventIndex, - title: event.title, - cue: event.cue, - colour: event.colour, - parent: event.parent, - } satisfies FilterableEvent); - break; - } - eventIndex++; - } + title: entry.title, + cue: entry.cue, + colour: entry.colour, + parent: entry.parent, + match: null, + } satisfies FilterableEvent, + ], + }; } - - return { results, error: null }; + eventIndex++; } - /** Returns maxResults of entries which carry a cue and match the cue field */ - function searchByCue(searchString: string) { - // indexes exposed to the UI are 1-based - let eventIndex = 1; - // limit amount of results we show - let remaining = maxResults; - const results: FilterableEntry[] = []; + return empty; + } - for (let i = 0; i < data.length; i++) { - if (remaining <= 0) { + /** + * Matches entries on a single field when one is selected, otherwise on every text field. + * Results keep rundown order, which keeps them predictable during a show. + */ + function searchByField(key: MaybeString, searchString: string) { + const results: FilterableEntry[] = []; + let total = 0; + // indexes exposed to the UI are 1-based + let eventIndex = 1; + + for (let i = 0; i < data.length; i++) { + const entry = data[i]; + const isEvent = isOntimeEvent(entry); + const currentEventIndex = eventIndex; + if (isEvent) { + eventIndex++; + } + + if (!isEvent && !isOntimeGroup(entry) && !isOntimeMilestone(entry)) { + // delays carry no text to search + continue; + } + + const candidates = getSearchableFields(entry, customFields).filter( + (field) => key === null || field.key === key, + ); + + let match: FinderMatch | null = null; + for (const field of candidates) { + const matchIndex = field.value.toLowerCase().indexOf(searchString); + if (matchIndex !== -1) { + match = { label: field.label, excerpt: makeExcerpt(field.value, matchIndex, searchString.length) }; break; } - const entry = data[i]; - if (isOntimeEvent(entry)) { - if (entry.cue.toLowerCase().includes(searchString)) { - remaining--; - results.push({ - type: SupportedEntry.Event, - id: entry.id, - index: i, - eventIndex, - title: entry.title, - cue: entry.cue, - colour: entry.colour, - parent: entry.parent, - } satisfies FilterableEvent); - } - eventIndex++; - } else if (isOntimeMilestone(entry)) { - // milestones carry a cue and show it in the rundown, so they belong in a cue search - if (entry.cue.toLowerCase().includes(searchString)) { - remaining--; - results.push({ - type: SupportedEntry.Milestone, - id: entry.id, - index: i, - title: entry.title, - cue: entry.cue, - colour: entry.colour, - parent: entry.parent, - } satisfies FilterableMilestone); - } - } } - return { results, error: null }; + + if (match === null) { + continue; + } + + total++; + if (results.length >= maxResults) { + continue; + } + + if (isEvent) { + results.push({ + type: SupportedEntry.Event, + id: entry.id, + index: i, + eventIndex: currentEventIndex, + title: entry.title, + cue: entry.cue, + colour: entry.colour, + parent: entry.parent, + match, + } satisfies FilterableEvent); + } else if (isOntimeGroup(entry)) { + results.push({ + type: SupportedEntry.Group, + id: entry.id, + index: i, + title: entry.title, + colour: entry.colour, + match, + } satisfies FilterableGroup); + } else { + results.push({ + type: SupportedEntry.Milestone, + id: entry.id, + index: i, + title: entry.title, + cue: entry.cue, + colour: entry.colour, + parent: entry.parent, + match, + } satisfies FilterableMilestone); + } } - /** Returns maxResults of OntimeEvents that match the title field*/ - function searchByTitle(searchString: string) { - // indexes exposed to the UI are 1-based - let eventIndex = 1; - // limit amount of results we show - let remaining = maxResults; - const results: FilterableEntry[] = []; - - for (let i = 0; i < data.length; i++) { - if (remaining <= 0) { - break; - } - - const entry = data[i]; - if (isOntimeEvent(entry)) { - if (entry.title.toLowerCase().includes(searchString)) { - remaining--; - results.push({ - type: SupportedEntry.Event, - id: entry.id, - index: i, - eventIndex, - title: entry.title, - cue: entry.cue, - colour: entry.colour, - parent: entry.parent, - } satisfies FilterableEvent); - } - eventIndex++; - } else if (isOntimeGroup(entry)) { - if (entry.title.toLowerCase().includes(searchString)) { - remaining--; - results.push({ - type: SupportedEntry.Group, - id: entry.id, - index: i, - title: entry.title, - colour: entry.colour, - } satisfies FilterableGroup); - } - } else if (isOntimeMilestone(entry)) { - if (entry.title.toLowerCase().includes(searchString)) { - remaining--; - results.push({ - type: SupportedEntry.Milestone, - id: entry.id, - index: i, - title: entry.title, - cue: entry.cue, - colour: entry.colour, - parent: entry.parent, - } satisfies FilterableMilestone); - } - } - } - return { results, error: null }; - } - }, - [data], - ); + return { results, error: null, total }; + } + }, [data, customFields, filters, searchValue]); const select = useCallback( (selectedEvent: FilterableEntry) => { @@ -235,15 +289,11 @@ export default function useFinder() { [selectAndRevealEntry], ); - /** clear results when source data changes */ - useEffect(() => { - setResults([]); - setError(null); - // fake a submit event to re-run the search - if (lastSearchString.current) { - find({ target: { value: lastSearchString.current } } as ChangeEvent); - } - }, [data, find]); - - return { find, select, results, error }; + return { select, results, error, total, filters }; +} + +/** Replaces any filter prefix in the current value, keeping whatever the user already typed */ +export function applyFilter(currentValue: string, filters: FinderFilter[], filterKey: string): string { + const { searchString } = parseQuery(currentValue.trim().toLowerCase(), filters); + return searchString ? `${filterKey} ${searchString}` : `${filterKey} `; } diff --git a/e2e/tests/features/209-rundown-shortcuts.spec.ts b/e2e/tests/features/209-rundown-shortcuts.spec.ts index b5828e8dd..e84b6b01f 100644 --- a/e2e/tests/features/209-rundown-shortcuts.spec.ts +++ b/e2e/tests/features/209-rundown-shortcuts.spec.ts @@ -356,6 +356,112 @@ test('Finder searches milestones by cue', async ({ page }) => { await expect(page.getByTestId('finder-result')).toHaveCount(1); }); +test('Finder searches notes and reports the matching field', async ({ page }) => { + await page.goto('/rundown'); + await page.getByRole('button', { name: 'Edit' }).click(); + + // clear rundown + await page.getByRole('button', { name: 'Rundown menu' }).click(); + await page.getByRole('menuitem', { name: 'Clear all' }).click(); + await page.getByRole('button', { name: 'Delete all' }).click(); + await expect(page.getByTestId('rundown-event')).toHaveCount(0); + + await page.getByRole('button', { name: 'Create Event' }).click(); + await expect(page.getByTestId('rundown-event')).toHaveCount(1); + await page.getByTestId('entry-1').getByTestId('entry__title').fill('opening remarks'); + await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter'); + + // the note is not shown on the rundown row, so it can only be reached by searching + await page.getByTestId('entry-1').click(); + await page.getByLabel('Note', { exact: true }).fill('remember the zebrafish tank'); + await page.getByLabel('Note', { exact: true }).press('Tab'); + + await page.keyboard.press('ControlOrMeta+f'); + await expect(page.getByPlaceholder('Search...')).toBeVisible(); + + // a bare query now reaches the note, and the row explains which field matched + await page.getByPlaceholder('Search...').fill('zebrafish'); + await expect(page.getByTestId('finder-result')).toHaveCount(1); + await expect(page.getByTestId('finder-result-match')).toContainText('Note'); + await expect(page.getByTestId('finder-result-match')).toContainText('zebrafish'); + + // scoping to the title excludes it again + await page.getByPlaceholder('Search...').fill('title zebrafish'); + await expect(page.getByTestId('finder-result')).toHaveCount(0); +}); + +test('Finder filter badges scope the search', async ({ page }) => { + await page.goto('/rundown'); + await page.getByRole('button', { name: 'Edit' }).click(); + + // clear rundown + await page.getByRole('button', { name: 'Rundown menu' }).click(); + await page.getByRole('menuitem', { name: 'Clear all' }).click(); + await page.getByRole('button', { name: 'Delete all' }).click(); + await expect(page.getByTestId('rundown-event')).toHaveCount(0); + + await page.getByRole('button', { name: 'Create Event' }).click(); + await expect(page.getByTestId('rundown-event')).toHaveCount(1); + await page.getByTestId('entry-1').getByTestId('entry__title').fill('sound check'); + await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter'); + + await page.keyboard.press('ControlOrMeta+f'); + await expect(page.getByPlaceholder('Search...')).toBeVisible(); + + // typing first, then narrowing with a badge, keeps the search text + await page.getByPlaceholder('Search...').fill('sound'); + await expect(page.getByTestId('finder-result')).toHaveCount(1); + + const filters = page.getByTestId('finder-filters'); + await filters.getByRole('button', { name: 'Note', exact: true }).click(); + await expect(page.getByPlaceholder('Search...')).toHaveValue('note sound'); + await expect(page.getByTestId('finder-result')).toHaveCount(0); + + // switching to another badge replaces the filter rather than stacking + await filters.getByRole('button', { name: 'Title', exact: true }).click(); + await expect(page.getByPlaceholder('Search...')).toHaveValue('title sound'); + await expect(page.getByTestId('finder-result')).toHaveCount(1); + await expect(page.getByTestId('finder-count')).toContainText('1 result'); + + // custom fields defined by the project are offered as filters too + await expect(filters.getByRole('button', { name: 'Song', exact: true })).toBeVisible(); + await expect(filters.getByRole('button', { name: 'Artist', exact: true })).toBeVisible(); +}); + +test('Finder searches custom fields', async ({ page }) => { + await page.goto('/rundown'); + await page.getByRole('button', { name: 'Edit' }).click(); + + // clear rundown + await page.getByRole('button', { name: 'Rundown menu' }).click(); + await page.getByRole('menuitem', { name: 'Clear all' }).click(); + await page.getByRole('button', { name: 'Delete all' }).click(); + await expect(page.getByTestId('rundown-event')).toHaveCount(0); + + await page.getByRole('button', { name: 'Create Event' }).click(); + await expect(page.getByTestId('rundown-event')).toHaveCount(1); + await page.getByTestId('entry-1').getByTestId('entry__title').fill('interval'); + await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter'); + + // custom field values are not shown on the rundown row + await page.getByTestId('entry-1').click(); + await page.getByLabel('Artist', { exact: true }).fill('zebrafish collective'); + await page.getByLabel('Artist', { exact: true }).press('Tab'); + + await page.keyboard.press('ControlOrMeta+f'); + await expect(page.getByPlaceholder('Search...')).toBeVisible(); + + // a bare query reaches custom field values, naming the field that matched + await page.getByPlaceholder('Search...').fill('zebrafish'); + await expect(page.getByTestId('finder-result')).toHaveCount(1); + await expect(page.getByTestId('finder-result-match')).toContainText('Artist'); + + // and the field can be scoped explicitly + await page.getByTestId('finder-filters').getByRole('button', { name: 'Artist', exact: true }).click(); + await expect(page.getByPlaceholder('Search...')).toHaveValue('Artist zebrafish'); + await expect(page.getByTestId('finder-result')).toHaveCount(1); +}); + test('Open settings', async ({ page }) => { await page.goto('/editor'); await expect(page.getByTestId('editor-container')).toBeVisible();