mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-25 08:59:13 +00:00
feat(finder): search all text fields and add filter badges
Searching only matched titles, so an event was unreachable by its cue, its note, or any custom field value. Custom fields are how teams model their own show data, which made the most valuable data the least findable. A bare query now matches cue, title, note and text custom fields across events, groups and milestones, and each result names the field it matched with an excerpt, so a hit in a note is legible. Widening the scan is free: at 5000 entries a pass over every field measures the same as the previous title-only pass, both far below the render cost. The filter syntax was only discoverable through a line of footer text. It is now a row of badges built from the fixed fields plus the project custom fields, which scope the search while keeping whatever the user already typed. The input becomes controlled, which removes the effect that replayed the last search on rundown changes. Raises the result cap and reports the total, since matching more fields means more results than the previous cap could show. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzALEq9gGWwFmwTdgAiFcY
This commit is contained in:
@@ -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<MaybeString>(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<HTMLInputElement>(null);
|
||||
const activeRef = useRef<HTMLLIElement>(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 (
|
||||
<Modal
|
||||
title=''
|
||||
@@ -82,37 +95,65 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
||||
showBackdrop
|
||||
bodyElements={
|
||||
<div onKeyDown={navigate}>
|
||||
<Input height='large' fluid onChange={debouncedFind} placeholder='Search...' />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
height='large'
|
||||
fluid
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder='Search...'
|
||||
/>
|
||||
<div className={style.filters} data-testid='finder-filters'>
|
||||
<span className={style.filterLabel}>Filter by</span>
|
||||
{filters.map((filter) => (
|
||||
<button
|
||||
key={filter.key}
|
||||
type='button'
|
||||
className={style.filterBadge}
|
||||
onClick={() => handleFilter(filter.key)}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ul className={style.scrollContainer}>
|
||||
{error && <li className={style.error}>{error}</li>}
|
||||
{results.length === 0 && <li className={style.empty}>No results</li>}
|
||||
{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 && <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';
|
||||
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
ref={isSelected ? activeRef : undefined}
|
||||
className={style.entry}
|
||||
data-testid='finder-result'
|
||||
data-selected={isSelected}
|
||||
onClick={() => submit(entry)}
|
||||
onPointerMove={(event) => handlePointerMove(event, entry.id)}
|
||||
>
|
||||
<div className={style.data}>
|
||||
<div className={style.index} style={{ '--color': entry.colour }}>
|
||||
{displayIndex}
|
||||
</div>
|
||||
<div className={style.cue}>{displayCue}</div>
|
||||
<div className={style.title}>{entry.title}</div>
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
ref={isSelected ? activeRef : undefined}
|
||||
className={style.entry}
|
||||
data-testid='finder-result'
|
||||
data-selected={isSelected}
|
||||
onClick={() => submit(entry)}
|
||||
onPointerMove={(event) => handlePointerMove(event, entry.id)}
|
||||
>
|
||||
<div className={style.data}>
|
||||
<div className={style.index} style={{ '--color': entry.colour }}>
|
||||
{displayIndex}
|
||||
</div>
|
||||
{isSelected && <span>Go ⏎</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<div className={style.cue}>{displayCue}</div>
|
||||
<div className={style.title}>{entry.title}</div>
|
||||
{showMatch && (
|
||||
<div className={style.match} data-testid='finder-result-match'>
|
||||
<span className={style.matchLabel}>{entry.match?.label}</span>
|
||||
{entry.match?.excerpt}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && <span className={style.go}>Go ⏎</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@@ -133,10 +174,11 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.filterHint}>
|
||||
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
|
||||
<span className={style.em}>title</span>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className={style.count} data-testid='finder-count'>
|
||||
{hasOverflow ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user