diff --git a/apps/client/src/views/editor/finder/useFinder.tsx b/apps/client/src/views/editor/finder/useFinder.tsx index bd532866b..feb422240 100644 --- a/apps/client/src/views/editor/finder/useFinder.tsx +++ b/apps/client/src/views/editor/finder/useFinder.tsx @@ -28,7 +28,10 @@ type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone; type FinderFilter = { key: string; label: string }; -/** The fields common to every project, custom fields are appended at runtime */ +/** + * 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' }, @@ -58,6 +61,7 @@ type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: numbe const noResults: SearchOutcome = { results: [], error: null, total: 0 }; +/** 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, @@ -71,34 +75,6 @@ function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber } satisfies FinderResult; } -type SearchableField = FinderFilter & { value: string }; - -/** - * 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: SearchableEntry, customFields: CustomFields): SearchableField[] { - const fields: SearchableField[] = []; - - if ('cue' in entry && entry.cue) { - fields.push({ key: 'cue', label: 'Cue', value: entry.cue }); - } - if (entry.title) { - fields.push({ key: 'title', label: 'Title', value: entry.title }); - } - if (entry.note) { - fields.push({ key: 'note', label: 'Note', value: entry.note }); - } - for (const [key, value] of Object.entries(entry.custom)) { - const definition = customFields[key]; - if (value && definition?.type === 'text') { - 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); @@ -106,22 +82,44 @@ function makeExcerpt(value: string, matchIndex: number, searchLength: number): s return `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`; } -/** The first field of an entry to contain the search string, if any */ +/** + * 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 { - for (const field of getSearchableFields(entry, customFields)) { - if (filterKey !== null && field.key !== filterKey) { + 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 matchIndex = field.value.toLowerCase().indexOf(searchString); - if (matchIndex !== -1) { - return { key: field.key, label: field.label, excerpt: makeExcerpt(field.value, matchIndex, searchString.length) }; - } + const custom = check(key, definition.label || key, value); + if (custom) return custom; } + return null; }