From 94e0cfa338ceecfa8a1b77a1cff1e9d4a6079d6c Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 21 Aug 2026 21:03:43 +0200 Subject: [PATCH] refactor(finder): improve feature UX Search every text field with optional filters, keep result selection stable, and expose clearer match context and counts. Cover query parsing, matching, mixed-entry indexing, and the reveal flow. --- .../rundown/placements/FinderPlacement.tsx | 14 +- .../rundown/rundown-event/RundownEvent.tsx | 1 + .../views/editor/finder/Finder.module.scss | 67 ++- .../client/src/views/editor/finder/Finder.tsx | 169 ++++--- .../src/views/editor/finder/useFinder.test.ts | 162 +++++++ .../src/views/editor/finder/useFinder.tsx | 436 +++++++++--------- .../features/209-rundown-shortcuts.spec.ts | 50 +- 7 files changed, 622 insertions(+), 277 deletions(-) create mode 100644 apps/client/src/views/editor/finder/useFinder.test.ts diff --git a/apps/client/src/features/rundown/placements/FinderPlacement.tsx b/apps/client/src/features/rundown/placements/FinderPlacement.tsx index 65e72547b..859d1847b 100644 --- a/apps/client/src/features/rundown/placements/FinderPlacement.tsx +++ b/apps/client/src/features/rundown/placements/FinderPlacement.tsx @@ -8,10 +8,16 @@ export default memo(FinderPlacement); function FinderPlacement() { const [isOpen, handler] = useDisclosure(); - useHotkeys([ - ['mod + f', handler.toggle, { preventDefault: true }], - ['Escape', handler.close, { preventDefault: true }], - ]); + /** + * The empty tagsToIgnore is significant: by default the hook skips input elements, + * which would make the shortcut dead while editing an entry. + * + * This opens rather than toggles. Toggling on a key that also mounts and unmounts the + * dialog races against it, and browsers treat a repeated find shortcut as "focus the + * search again" rather than "close it". The finder selects its input instead, and + * Escape closes. + */ + useHotkeys([['mod + f', handler.open, { preventDefault: true }]], []); if (isOpen) { return ; diff --git a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx index 2c0428e92..0afbe8bdd 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx @@ -312,6 +312,7 @@ export default function RundownEvent({ onClick={handleFocusClick} onContextMenu={onContextMenu} data-testid='rundown-event' + data-selected={isSelected} {...(isPlaying ? { 'data-running': true } : {})} > diff --git a/apps/client/src/views/editor/finder/Finder.module.scss b/apps/client/src/views/editor/finder/Finder.module.scss index 99c2a318e..78ccae66b 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'] { @@ -18,21 +21,47 @@ color: $label-gray; } +.more { + padding-inline: 0.5rem; + padding-block: 0.75rem; + font-size: calc(1rem - 2px); + color: $label-gray; + border-top: 1px solid $gray-1000; + text-align: center; +} + .error { 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; +} + .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; - background-color: var(--color, $gray-1000); + // background and text colour come from getAccessibleColour, which keeps the + // number legible whatever colour the user gave the entry border-radius: 2px; padding-block: 0.25rem; width: 3.5rem; @@ -42,14 +71,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 +111,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 +147,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 a3717b097..e5e4c37fb 100644 --- a/apps/client/src/views/editor/finder/Finder.tsx +++ b/apps/client/src/views/editor/finder/Finder.tsx @@ -1,11 +1,12 @@ -import { useDebouncedCallback } from '@mantine/hooks'; -import { SupportedEntry } from 'ontime-types'; -import { KeyboardEvent, useState } from 'react'; +import { MaybeString } from 'ontime-types'; +import { KeyboardEvent, useDeferredValue, useEffect, useRef, useState } from 'react'; +import ToggleButton from '../../../common/components/buttons/ToggleButton'; import Input from '../../../common/components/input/input/Input'; import Kbd from '../../../common/components/kbd/Kbd'; import Modal from '../../../common/components/modal/Modal'; -import useFinder from './useFinder'; +import { getAccessibleColour } from '../../../common/utils/styleUtils'; +import useFinder, { FinderResult } from './useFinder'; import style from './Finder.module.scss'; @@ -15,46 +16,76 @@ interface FinderProps { } export default function Finder({ isOpen, onClose }: FinderProps) { - const { find, select, results, error } = useFinder(); - const [selected, setSelected] = useState(0); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState(null); + const [selectedId, setSelectedId] = useState(null); - const debouncedFind = useDebouncedCallback(find, 100); + /** + * 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, appliedFilter } = useFinder(deferredSearch, filter); + + const inputRef = useRef(null); + const activeRef = useRef(null); + + /** + * 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 + */ + const activeIndex = Math.max( + 0, + results.findIndex((entry) => entry.id === selectedId), + ); + const activeEntry = results.at(activeIndex); + + /** keep the highlighted entry in view while navigating with the keyboard */ + useEffect(() => { + activeRef.current?.scrollIntoView({ block: 'nearest' }); + }, [activeEntry?.id]); const navigate = (event: KeyboardEvent) => { + // pressing the search shortcut again selects the query, ready to be replaced + if ((event.metaKey || event.ctrlKey) && event.key === 'f') { + event.preventDefault(); + inputRef.current?.select(); + return; + } + // all operations need results if (results.length === 0) { return; } if (event.key === 'ArrowDown') { - setSelected((prev) => (prev + 1) % results.length); + setSelectedId(results[(activeIndex + 1) % results.length].id); } if (event.key === 'ArrowUp') { - setSelected((prev) => (prev - 1 + results.length) % results.length); + setSelectedId(results[(activeIndex - 1 + results.length) % results.length].id); } if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); - submit(); + submit(activeEntry); } }; - const submit = () => { - const selectedEvent = results[selected]; - select(selectedEvent); + const submit = (entry: FinderResult | undefined) => { + if (!entry) { + return; + } + select(entry); onClose(); }; - const handleMouseMoveEvent = (event: React.MouseEvent) => { - const target = event.target as HTMLElement; - const li = target.closest('li'); - if (li) { - const index = Number(li.dataset.index); - if (!isNaN(index)) { - setSelected(index); - } - } + /** Scopes the search to a single field, or back to all fields when tapped again */ + const handleFilter = (filterKey: string) => { + setFilter((previous) => (previous === filterKey ? null : filterKey)); + inputRef.current?.focus(); }; + const hiddenResults = total - results.length; + return ( - -
    + setSearch(event.target.value)} + placeholder='Search...' + /> +
    + Filter by + {filters.map((option) => ( + handleFilter(option.key)} + > + {option.label} + + ))} +
    +
      {error &&
    • {error}
    • } - {results.length === 0 &&
    • No results
    • } - {results.length > 0 && - results.map((entry, index) => { - const isSelected = selected === index; - 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; + // the title and cue are already on the row, a match anywhere else needs showing + const showMatch = entry.match !== null && entry.match.key !== 'title' && entry.match.key !== 'cue'; - return ( -
    • -
      -
      - {displayIndex} -
      -
      {displayCue}
      -
      {entry.title}
      + return ( +
    • submit(entry)} + onPointerMove={() => setSelectedId(entry.id)} + > +
      +
      + {entry.eventIndex ?? '-'}
      - {isSelected && Go ⏎} -
    • - ); - })} +
      {entry.cue}
      +
      {entry.title}
      + {showMatch && ( +
      + {entry.match?.label} + {entry.match?.excerpt} +
      + )} + + {isSelected && Go ⏎} + + ); + })} + {hiddenResults > 0 && ( +
    • + {hiddenResults} more {hiddenResults === 1 ? 'result' : 'results'} — keep typing to narrow the search +
    • + )}
    } @@ -112,10 +176,11 @@ export default function Finder({ isOpen, onClose }: FinderProps) { Close -
    - Filter by cue, index, or - title -
    + {total > 0 && ( +
    + {hiddenResults > 0 ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`} +
    + )} } /> diff --git a/apps/client/src/views/editor/finder/useFinder.test.ts b/apps/client/src/views/editor/finder/useFinder.test.ts new file mode 100644 index 000000000..cedd52966 --- /dev/null +++ b/apps/client/src/views/editor/finder/useFinder.test.ts @@ -0,0 +1,162 @@ +import { CustomFields, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone, SupportedEntry } from 'ontime-types'; + +import { parseQuery, searchByIndex, searchByText } from './useFinder'; + +function makeEvent(id: string, overrides: Partial = {}): OntimeEvent { + return { + type: SupportedEntry.Event, + id, + cue: '', + title: '', + note: '', + colour: '#000000', + custom: {}, + parent: null, + ...overrides, + } as OntimeEvent; +} + +function makeGroup(id: string, overrides: Partial = {}): OntimeGroup { + return { + type: SupportedEntry.Group, + id, + title: '', + note: '', + colour: '#000000', + custom: {}, + ...overrides, + } as OntimeGroup; +} + +function makeMilestone(id: string, overrides: Partial = {}): OntimeMilestone { + return { + type: SupportedEntry.Milestone, + id, + cue: '', + title: '', + note: '', + colour: '#000000', + custom: {}, + parent: null, + ...overrides, + } as OntimeMilestone; +} + +function makeDelay(id: string): OntimeDelay { + return { type: SupportedEntry.Delay, id, duration: 1000, parent: null }; +} + +describe('parseQuery()', () => { + const filters = [ + { key: 'cue', label: 'Cue' }, + { key: 'Camera_Notes', label: 'Camera Notes' }, + ]; + + it.each([ + ['cue 12', { filterKey: 'cue', searchString: '12' }], + ['cue:12', { filterKey: 'cue', searchString: '12' }], + ['camera_notes:wide', { filterKey: 'Camera_Notes', searchString: 'wide' }], + ])('parses the field prefix in %s', (searchValue, expected) => { + expect(parseQuery(searchValue, filters)).toStrictEqual(expected); + }); + + it('keeps an unprefixed query as a search across all fields', () => { + expect(parseQuery('zebrafish', filters)).toStrictEqual({ filterKey: null, searchString: 'zebrafish' }); + }); + + it('recognises a filter before any search text has been entered', () => { + expect(parseQuery('cue', filters)).toStrictEqual({ filterKey: 'cue', searchString: '' }); + }); +}); + +describe('searchByText()', () => { + const customFields: CustomFields = { + Camera_Notes: { type: 'text', label: 'Camera Notes', colour: '#000000' }, + Slide: { type: 'image', label: 'Slide', colour: '#000000' }, + }; + + it('searches cue, title, note, and text custom fields in rundown order', () => { + const data = [ + makeMilestone('milestone', { cue: 'needle' }), + makeGroup('group', { title: 'needle' }), + makeEvent('note', { note: 'find the needle here' }), + makeEvent('custom', { custom: { Camera_Notes: 'needle' } }), + ]; + + const outcome = searchByText(data, customFields, null, 'needle'); + + expect(outcome.results.map(({ id, match }) => ({ id, field: match?.key }))).toStrictEqual([ + { id: 'milestone', field: 'cue' }, + { id: 'group', field: 'title' }, + { id: 'note', field: 'note' }, + { id: 'custom', field: 'Camera_Notes' }, + ]); + expect(outcome.total).toBe(4); + }); + + it('searches only the selected field', () => { + const data = [ + makeEvent('title', { title: 'needle' }), + makeEvent('note', { note: 'needle' }), + makeEvent('custom', { custom: { Camera_Notes: 'needle' } }), + ]; + + const outcome = searchByText(data, customFields, 'note', 'needle'); + + expect(outcome.results.map((result) => result.id)).toStrictEqual(['note']); + expect(outcome.total).toBe(1); + }); + + it('reports the first matching field so the result can explain why it matched', () => { + const data = [makeEvent('event', { cue: 'NEEDLE', title: 'another needle' })]; + + const outcome = searchByText(data, customFields, null, 'needle'); + + expect(outcome.results[0].match).toStrictEqual({ key: 'cue', label: 'Cue', excerpt: 'NEEDLE' }); + }); + + it('does not search image custom fields', () => { + const data = [makeEvent('image-only', { custom: { Slide: 'needle' } })]; + + expect(searchByText(data, customFields, null, 'needle')).toStrictEqual({ results: [], error: null, total: 0 }); + }); + + it('reports the full match count while limiting rendered results', () => { + const data = Array.from({ length: 51 }, (_, index) => makeEvent(String(index), { title: 'needle' })); + + const outcome = searchByText(data, customFields, null, 'needle'); + + expect(outcome.results).toHaveLength(50); + expect(outcome.total).toBe(51); + }); +}); + +describe('searchByIndex()', () => { + it('counts only events while preserving the flat rundown position', () => { + const data = [ + makeGroup('group'), + makeDelay('delay'), + makeEvent('first'), + makeMilestone('milestone'), + makeEvent('second'), + ]; + + const outcome = searchByIndex(data, '2'); + + expect(outcome.results).toHaveLength(1); + expect(outcome.results[0]).toMatchObject({ id: 'second', index: 4, eventIndex: 2 }); + expect(outcome.total).toBe(1); + }); + + it.each(['0', 'not-a-number'])('rejects invalid index %s', (index) => { + expect(searchByIndex([makeEvent('event')], index)).toStrictEqual({ + results: [], + error: 'Invalid index', + total: 0, + }); + }); + + it('returns no result when the event index is beyond the rundown', () => { + expect(searchByIndex([makeEvent('event')], '2')).toStrictEqual({ results: [], error: null, total: 0 }); + }); +}); diff --git a/apps/client/src/views/editor/finder/useFinder.tsx b/apps/client/src/views/editor/finder/useFinder.tsx index fdfda8a2f..7840edf52 100644 --- a/apps/client/src/views/editor/finder/useFinder.tsx +++ b/apps/client/src/views/editor/finder/useFinder.tsx @@ -1,239 +1,259 @@ -import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; -import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { + CustomFields, + EntryId, + MaybeNumber, + MaybeString, + OntimeEntry, + OntimeEvent, + OntimeGroup, + OntimeMilestone, + isOntimeDelay, + isOntimeEvent, +} 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; - id: EntryId; - index: number; - title: string; - colour: string; -}; +const indexFilter = 'index'; -type FilterableEvent = { - type: SupportedEntry.Event; +/** Everything except delays, which carry no text to search */ +type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone; + +type FinderFilter = { key: string; label: string }; + +/** + * Offered to the user as filter badges. Index is a positional lookup rather than a + * text field, so it is handled separately from the fields a search runs over. + */ +const staticFilters: FinderFilter[] = [ + { key: indexFilter, label: 'Index' }, + { key: 'cue', label: 'Cue' }, + { key: 'title', label: 'Title' }, + { key: 'note', label: 'Note' }, +]; + +/** Why an entry matched, so the UI can show the user */ +type FinderMatch = { key: string; label: string; excerpt: string }; + +export type FinderResult = { id: EntryId; + /** position in the flat rundown, which is how the rundown reveals an entry */ index: number; - eventIndex: number; + /** 1-based position among events, null for groups and milestones */ + eventIndex: MaybeNumber; title: string; + /** groups have no cue */ cue: string; colour: string; parent: MaybeString; + /** absent when the entry was found by index rather than by matching text */ + match: FinderMatch | null; }; -type FilterableMilestone = { - type: SupportedEntry.Milestone; - id: EntryId; - index: number; - title: string; - cue: string; - colour: string; - parent: MaybeString; -}; +type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: number }; -type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone; +const noResults: SearchOutcome = { results: [], error: null, total: 0 }; -export default function useFinder() { +/** Groups are the only searchable entry with neither a cue nor a parent */ +function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber, match: FinderMatch | null) { + return { + id: entry.id, + index, + eventIndex, + title: entry.title, + cue: 'cue' in entry ? entry.cue : '', + colour: entry.colour, + parent: 'parent' in entry ? entry.parent : null, + match, + } satisfies FinderResult; +} + +/** 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 ? '…' : ''}`; +} + +/** + * The first field of an entry to contain the search string, if any. + * Fields are tried in the order we prefer to report a match. + */ +function findMatch( + entry: SearchableEntry, + customFields: CustomFields, + filterKey: MaybeString, + searchString: string, +): FinderMatch | null { + function check(key: string, label: string, value: string): FinderMatch | null { + if (!value || (filterKey !== null && key !== filterKey)) { + return null; + } + const matchIndex = value.toLowerCase().indexOf(searchString); + if (matchIndex === -1) { + return null; + } + return { key, label, excerpt: makeExcerpt(value, matchIndex, searchString.length) }; + } + + // groups have no cue, the rest is common to every searchable entry + const fromCue = 'cue' in entry ? check('cue', 'Cue', entry.cue) : null; + const match = fromCue ?? check('title', 'Title', entry.title) ?? check('note', 'Note', entry.note); + if (match !== null) { + return match; + } + + // custom fields are named by the project, so these can only be reached generically + for (const [key, value] of Object.entries(entry.custom)) { + const definition = customFields[key]; + if (definition?.type !== 'text') { + continue; + } + const custom = check(key, definition.label || key, value); + if (custom) return custom; + } + + return null; +} + +/** + * 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 that typing agrees with the filter badges. + */ +export function parseQuery(searchValue: string, filters: FinderFilter[]) { + for (const filter of filters) { + // the search value is already lowercased, custom field keys are not + const prefix = filter.key.toLowerCase(); + if (searchValue === prefix) { + 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 }; +} + +/** Finds the single event at a 1-based position in the rundown */ +export function searchByIndex(data: OntimeEntry[], indexString: string): SearchOutcome { + const target = Number(indexString); + if (isNaN(target) || target < 1) { + return { ...noResults, error: 'Invalid index' }; + } + + let eventIndex = 0; + for (let i = 0; i < data.length; i++) { + const entry = data[i]; + if (!isOntimeEvent(entry)) { + continue; + } + eventIndex++; + if (eventIndex === target) { + return { results: [toResult(entry, i, eventIndex, null)], error: null, total: 1 }; + } + } + + return noResults; +} + +/** + * 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. + */ +export function searchByText( + data: OntimeEntry[], + customFields: CustomFields, + filterKey: MaybeString, + searchString: string, +): SearchOutcome { + const results: FinderResult[] = []; + let total = 0; + // indexes exposed to the UI are 1-based + let eventIndex = 0; + + for (let i = 0; i < data.length; i++) { + const entry = data[i]; + if (isOntimeDelay(entry)) { + continue; + } + const isEvent = isOntimeEvent(entry); + if (isEvent) { + eventIndex++; + } + + const match = findMatch(entry, customFields, filterKey, searchString); + if (match === null) { + continue; + } + + total++; + if (results.length < maxResults) { + results.push(toResult(entry, i, isEvent ? eventIndex : null, match)); + } + } + + return { results, error: null, total }; +} + +/** + * @param searchValue - the text the user is looking for + * @param activeFilter - a field selected from the filter badges, if any + */ +export default function useFinder(searchValue: string, activeFilter: MaybeString) { 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: the 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, appliedFilter } = useMemo(() => { + if (data.length === 0) { + return { ...noResults, error: 'No data', appliedFilter: activeFilter }; + } - const searchValue = event.target.value.toLowerCase(); - lastSearchString.current = searchValue; + const normalised = searchValue.trim().toLowerCase(); + if (normalised === '') { + return { ...noResults, appliedFilter: activeFilter }; + } - if (searchValue.startsWith('index ')) { - const searchString = searchValue.slice('index '.length).trim(); - const { results, error } = searchByIndex(searchString); - setResults(results); - setError(error); - return; - } + /** + * A selected badge wins, but typing a keyword still works for anyone who knows them, + * and lights up the matching badge rather than being silently ignored. + */ + const { filterKey, searchString } = activeFilter + ? { filterKey: activeFilter, searchString: normalised } + : parseQuery(normalised, filters); - 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' }; - } - - if (searchIndex > data.length) { - return { results: [], error: null }; - } - - // 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({ - type: SupportedEntry.Event, - id: event.id, - index: i, - eventIndex, - title: event.title, - cue: event.cue, - colour: event.colour, - parent: event.parent, - } satisfies FilterableEvent); - break; - } - eventIndex++; - } - } - - return { results, error: null }; - } - - /** Returns maxResults of OntimeEvents that 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: FilterableEvent[] = []; - - for (let i = 0; i < data.length; i++) { - if (remaining <= 0) { - break; - } - const event = data[i]; - if (isOntimeEvent(event)) { - if (event.cue.toLowerCase().includes(searchString)) { - remaining--; - results.push({ - type: SupportedEntry.Event, - id: event.id, - index: i, - eventIndex, - title: event.title, - cue: event.cue, - colour: event.colour, - parent: event.parent, - } satisfies FilterableEvent); - } - eventIndex++; - } - } - return { results, error: null }; - } - - /** 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], - ); + if (filterKey === indexFilter) { + return { ...searchByIndex(data, searchString), appliedFilter: filterKey }; + } + if (searchString === '') { + // a filter is selected, but there is nothing to match on yet + return { ...noResults, appliedFilter: filterKey }; + } + return { ...searchByText(data, customFields, filterKey, searchString), appliedFilter: filterKey }; + }, [data, customFields, filters, searchValue, activeFilter]); const select = useCallback( - (selectedEvent: FilterableEntry) => { - selectAndRevealEntry({ - id: selectedEvent.id, - index: selectedEvent.index, - parent: 'parent' in selectedEvent ? selectedEvent.parent : null, - }); + (result: FinderResult) => { + selectAndRevealEntry({ id: result.id, index: result.index, parent: result.parent }); }, [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, appliedFilter }; } diff --git a/e2e/tests/features/209-rundown-shortcuts.spec.ts b/e2e/tests/features/209-rundown-shortcuts.spec.ts index 32c38d794..967ac836d 100644 --- a/e2e/tests/features/209-rundown-shortcuts.spec.ts +++ b/e2e/tests/features/209-rundown-shortcuts.spec.ts @@ -222,15 +222,57 @@ test('Delete event', async ({ page }) => { await expect(page.getByRole('button', { name: 'Create Group' })).toBeVisible(); }); -test('Find in rundown', async ({ page }) => { +test('Finder searches the rundown and reveals a result', async ({ page }) => { await page.goto('/rundown'); - await expect(page.getByTestId('panel-rundown')).toBeVisible(); + 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); + + // two events, where the one we are looking for is identified only by its note + await page.getByRole('button', { name: 'Create Event' }).click(); + await expect(page.getByTestId('rundown-event')).toHaveCount(1); + await page.getByTestId('entry-1').click(); + await page.getByTestId('entry__title').press('Escape'); + await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+E'); + await expect(page.getByTestId('rundown-event')).toHaveCount(2); + + await page.getByTestId('entry-1').getByTestId('entry__title').fill('opening'); + await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter'); + await page.getByTestId('entry-2').getByTestId('entry__title').fill('closing'); + await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter'); + + await page.getByTestId('entry-2').click(); + await page.getByLabel('Note', { exact: true }).fill('remember the zebrafish'); + await page.getByLabel('Note', { exact: true }).press('Tab'); + + // the shortcut has to work from a focused field, which is where it is usually reached for + await page.getByTestId('entry-2').getByTestId('entry__title').click(); await page.keyboard.press('ControlOrMeta+f'); - await expect(page.getByPlaceholder('Search...')).toBeVisible(); + await expect(page.getByPlaceholder('Search...')).toBeFocused(); - await page.keyboard.press('Escape'); + // a bare query reaches the note, and the result names the field it matched + await page.getByPlaceholder('Search...').fill('zebrafish'); + await expect(page.getByTestId('finder-result')).toHaveCount(1); + await expect(page.getByTestId('finder-result-match')).toContainText('Note'); + + // a badge scopes the search to one field, without putting syntax in the input + const titleFilter = page.getByTestId('finder-filters').getByRole('button', { name: 'Title', exact: true }); + await titleFilter.click(); + await expect(page.getByPlaceholder('Search...')).toHaveValue('zebrafish'); + await expect(page.getByTestId('finder-result')).toHaveCount(0); + + // pressing it again searches every field once more + await titleFilter.click(); + await expect(page.getByTestId('finder-result')).toHaveCount(1); + + // choosing a result closes the finder and selects the entry in the rundown + await page.getByPlaceholder('Search...').press('Enter'); await expect(page.getByPlaceholder('Search...')).toBeHidden(); + await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toHaveAttribute('data-selected', 'true'); }); test('Open settings', async ({ page }) => {