refactor(finder): flatten the result type and trim the tests

The three-variant result union differed only in whether an entry had a
cue, an event index and a parent, and the UI narrowed it with runtime
property checks anyway, so the discrimination bought nothing while
costing three near-identical branches to build a result. One flat type
removes those branches and the checks around them.

Skipping non-searchable entries asked whether an entry was a group or a
milestone, then asked again when building the result. It now asks once
whether it is a delay, which is what the guard actually meant.

The search functions move out of the memo closure to the module, where
they are ordinary pure functions.

Whether to show the matched field compared against its display label, so
a custom field labelled "Title" would have had its match hidden. It now
compares the field key.

Drops the review document, which had served its purpose, and reduces the
finder tests to a single one covering the path a user takes: open from a
focused field, find an entry by its note, scope with a badge, and reveal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzALEq9gGWwFmwTdgAiFcY
This commit is contained in:
Claude
2026-08-18 18:59:29 +00:00
parent 2fc072a9d3
commit bb221685ea
4 changed files with 170 additions and 800 deletions
@@ -1,10 +1,10 @@
import { EntryId, MaybeString, SupportedEntry } from 'ontime-types';
import { EntryId, MaybeString } from 'ontime-types';
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, { FinderResult } from './useFinder';
import style from './Finder.module.scss';
@@ -69,7 +69,7 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
}
};
const submit = (entry: FilterableEntry | undefined) => {
const submit = (entry: FinderResult | undefined) => {
if (!entry) {
return;
}
@@ -132,10 +132,8 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
{!error && results.length === 0 && <li className={style.empty}>No results</li>}
{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';
// 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 (
<li
@@ -149,9 +147,9 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
>
<div className={style.data}>
<div className={style.index} style={{ '--color': entry.colour }}>
{displayIndex}
{entry.eventIndex ?? '-'}
</div>
<div className={style.cue}>{displayCue}</div>
<div className={style.cue}>{entry.cue}</div>
<div className={style.title}>{entry.title}</div>
{showMatch && (
<div className={style.match} data-testid='finder-result-match'>
+139 -181
View File
@@ -1,12 +1,14 @@
import {
CustomFields,
EntryId,
MaybeNumber,
MaybeString,
OntimeEntry,
SupportedEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
isOntimeMilestone,
} from 'ontime-types';
import { useCallback, useMemo } from 'react';
@@ -21,69 +23,75 @@ const excerptPadding = 40;
const indexFilter = 'index';
/**
* A field the user can scope a search to.
* Custom fields are appended to these at runtime.
*/
const staticFilters = [
/** Everything except delays, which carry no text to search */
type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone;
type FinderFilter = { key: string; label: string };
/** The fields common to every project, custom fields are appended at runtime */
const staticFilters: FinderFilter[] = [
{ 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 };
/** Why an entry matched, so the UI can show the user */
type FinderMatch = { key: string; label: string; excerpt: string };
/** Describes why an entry matched, so the UI can show the user */
export type FinderMatch = { label: string; excerpt: string };
type FilterableBase = {
export type FinderResult = {
id: EntryId;
/** position in the flat rundown, which is how the rundown reveals an entry */
index: 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 FilterableGroup = FilterableBase & { type: SupportedEntry.Group };
type FilterableEvent = FilterableBase & {
type: SupportedEntry.Event;
eventIndex: number;
cue: string;
parent: MaybeString;
};
type FilterableMilestone = FilterableBase & {
type: SupportedEntry.Milestone;
cue: string;
parent: MaybeString;
};
type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: number };
export type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
const noResults: SearchOutcome = { results: [], error: null, total: 0 };
type SearchableField = { key: string; label: string; value: string };
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;
}
type SearchableField = FinderFilter & { value: string };
/**
* Collects the text fields of an entry, in the order we prefer to report a match.
* 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[] {
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 ('title' in entry && entry.title) {
if (entry.title) {
fields.push({ key: 'title', label: 'Title', value: entry.title });
}
if ('note' in entry && entry.note) {
if (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;
}
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 });
}
}
@@ -98,16 +106,34 @@ 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 */
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) {
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) };
}
}
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 the filter badges and typing agree.
* Both `cue 12` and `cue:12` are accepted so that typing agrees with the filter badges.
*/
function parseQuery(searchValue: string, filters: FinderFilter[]) {
for (const filter of filters) {
// the value is already lowercased, custom field keys are not
// the search 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}:`)) {
@@ -117,6 +143,67 @@ function parseQuery(searchValue: string, filters: FinderFilter[]) {
return { filterKey: null, searchString: searchValue };
}
/** Finds the single event at a 1-based position in the rundown */
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.
*/
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
@@ -127,7 +214,7 @@ export default function useFinder(searchValue: string, activeFilter: MaybeString
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
/** The filters offered to the user: fixed fields plus whatever the project defines */
/** The filters offered to the user: the fixed fields plus whatever the project defines */
const filters = useMemo<FinderFilter[]>(() => {
const customFilters = Object.entries(customFields)
.filter(([_key, field]) => field.type === 'text')
@@ -136,165 +223,36 @@ export default function useFinder(searchValue: string, activeFilter: MaybeString
}, [customFields]);
const { results, error, total, appliedFilter } = useMemo(() => {
const empty = { results: [] as FilterableEntry[], error: null, total: 0, appliedFilter: activeFilter };
if (!data || data.length === 0) {
return { ...empty, error: 'No data' };
if (data.length === 0) {
return { ...noResults, error: 'No data', appliedFilter: activeFilter };
}
const normalised = searchValue.trim().toLowerCase();
if (normalised === '') {
return empty;
return { ...noResults, appliedFilter: activeFilter };
}
/**
* A selected badge wins, but typing a prefix still works for anyone who knows the
* keywords, and lights up the matching badge rather than being silently ignored.
* 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 (filterKey === indexFilter) {
return { ...searchByIndex(searchString), appliedFilter: filterKey };
return { ...searchByIndex(data, searchString), appliedFilter: filterKey };
}
if (searchString === '') {
// a filter is selected, but there is nothing to match on yet
return empty;
}
return { ...searchByField(filterKey, searchString), appliedFilter: filterKey };
/** 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' };
}
let eventIndex = 1;
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (!isOntimeEvent(entry)) {
continue;
}
if (eventIndex === searchIndex) {
return {
error: null,
total: 1,
results: [
{
type: SupportedEntry.Event,
id: entry.id,
index: i,
eventIndex,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
match: null,
} satisfies FilterableEvent,
],
};
}
eventIndex++;
}
return empty;
}
/**
* 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;
}
}
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);
}
}
return { results, error: null, total };
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],
);