mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 06:04:05 +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:
@@ -3,11 +3,14 @@
|
|||||||
.error {
|
.error {
|
||||||
padding-inline: 0.5rem;
|
padding-inline: 0.5rem;
|
||||||
font-size: 1rem;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry[data-selected='true'] {
|
.entry[data-selected='true'] {
|
||||||
@@ -22,13 +25,49 @@
|
|||||||
color: $error-red;
|
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 {
|
.data {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'index cue'
|
'index cue'
|
||||||
'index title';
|
'index title'
|
||||||
|
'index match';
|
||||||
column-gap: 1rem;
|
column-gap: 1rem;
|
||||||
grid-template-rows: min-content 1fr;
|
grid-template-rows: min-content 1fr;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
.index {
|
.index {
|
||||||
grid-area: index;
|
grid-area: index;
|
||||||
@@ -42,14 +81,33 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
grid-area: title;
|
grid-area: title;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cue {
|
.cue {
|
||||||
grid-area: cue;
|
grid-area: cue;
|
||||||
font-size: calc(1rem - 2px);
|
font-size: calc(1rem - 2px);
|
||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
max-height: 1em;
|
|
||||||
min-height: 0;
|
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;
|
color: $label-gray;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterHint {
|
.count {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.em {
|
.go {
|
||||||
color: $ui-white;
|
white-space: nowrap;
|
||||||
margin-inline: 0.25rem;
|
padding-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hints {
|
.hints {
|
||||||
@@ -98,7 +157,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterHint {
|
.count {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useDebouncedCallback } from '@mantine/hooks';
|
|
||||||
import { EntryId, MaybeString, SupportedEntry } from 'ontime-types';
|
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 Input from '../../../common/components/input/input/Input';
|
||||||
import Kbd from '../../../common/components/kbd/Kbd';
|
import Kbd from '../../../common/components/kbd/Kbd';
|
||||||
import Modal from '../../../common/components/modal/Modal';
|
import Modal from '../../../common/components/modal/Modal';
|
||||||
import useFinder, { FilterableEntry } from './useFinder';
|
import useFinder, { applyFilter, FilterableEntry } from './useFinder';
|
||||||
|
|
||||||
import style from './Finder.module.scss';
|
import style from './Finder.module.scss';
|
||||||
|
|
||||||
@@ -15,14 +14,20 @@ interface FinderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Finder({ isOpen, onClose }: FinderProps) {
|
export default function Finder({ isOpen, onClose }: FinderProps) {
|
||||||
const { find, select, results, error } = useFinder();
|
const [search, setSearch] = useState('');
|
||||||
const [selectedId, setSelectedId] = useState<MaybeString>(null);
|
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 activeRef = useRef<HTMLLIElement>(null);
|
||||||
const lastPointer = useRef({ x: -1, y: -1 });
|
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:
|
* 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
|
* 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);
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title=''
|
title=''
|
||||||
@@ -82,37 +95,65 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
|||||||
showBackdrop
|
showBackdrop
|
||||||
bodyElements={
|
bodyElements={
|
||||||
<div onKeyDown={navigate}>
|
<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}>
|
<ul className={style.scrollContainer}>
|
||||||
{error && <li className={style.error}>{error}</li>}
|
{error && <li className={style.error}>{error}</li>}
|
||||||
{results.length === 0 && <li className={style.empty}>No results</li>}
|
{!error && results.length === 0 && <li className={style.empty}>No results</li>}
|
||||||
{results.length > 0 &&
|
{results.map((entry) => {
|
||||||
results.map((entry) => {
|
const isSelected = activeEntry?.id === entry.id;
|
||||||
const isSelected = activeEntry?.id === entry.id;
|
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
|
||||||
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
|
const displayCue = 'cue' in entry ? entry.cue : '';
|
||||||
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 (
|
return (
|
||||||
<li
|
<li
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
ref={isSelected ? activeRef : undefined}
|
ref={isSelected ? activeRef : undefined}
|
||||||
className={style.entry}
|
className={style.entry}
|
||||||
data-testid='finder-result'
|
data-testid='finder-result'
|
||||||
data-selected={isSelected}
|
data-selected={isSelected}
|
||||||
onClick={() => submit(entry)}
|
onClick={() => submit(entry)}
|
||||||
onPointerMove={(event) => handlePointerMove(event, entry.id)}
|
onPointerMove={(event) => handlePointerMove(event, entry.id)}
|
||||||
>
|
>
|
||||||
<div className={style.data}>
|
<div className={style.data}>
|
||||||
<div className={style.index} style={{ '--color': entry.colour }}>
|
<div className={style.index} style={{ '--color': entry.colour }}>
|
||||||
{displayIndex}
|
{displayIndex}
|
||||||
</div>
|
|
||||||
<div className={style.cue}>{displayCue}</div>
|
|
||||||
<div className={style.title}>{entry.title}</div>
|
|
||||||
</div>
|
</div>
|
||||||
{isSelected && <span>Go ⏎</span>}
|
<div className={style.cue}>{displayCue}</div>
|
||||||
</li>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -133,10 +174,11 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
|||||||
Close
|
Close
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.filterHint}>
|
{total > 0 && (
|
||||||
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
|
<div className={style.count} data-testid='finder-count'>
|
||||||
<span className={style.em}>title</span>
|
{hasOverflow ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,228 +1,282 @@
|
|||||||
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
import {
|
||||||
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
|
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 { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
||||||
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
|
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 = {
|
const indexFilter = 'index';
|
||||||
type: SupportedEntry.Group;
|
|
||||||
|
/**
|
||||||
|
* 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;
|
id: EntryId;
|
||||||
index: number;
|
index: number;
|
||||||
title: string;
|
title: string;
|
||||||
colour: string;
|
colour: string;
|
||||||
|
match: FinderMatch | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FilterableEvent = {
|
type FilterableGroup = FilterableBase & { type: SupportedEntry.Group };
|
||||||
|
type FilterableEvent = FilterableBase & {
|
||||||
type: SupportedEntry.Event;
|
type: SupportedEntry.Event;
|
||||||
id: EntryId;
|
|
||||||
index: number;
|
|
||||||
eventIndex: number;
|
eventIndex: number;
|
||||||
title: string;
|
|
||||||
cue: string;
|
cue: string;
|
||||||
colour: string;
|
|
||||||
parent: MaybeString;
|
parent: MaybeString;
|
||||||
};
|
};
|
||||||
|
type FilterableMilestone = FilterableBase & {
|
||||||
type FilterableMilestone = {
|
|
||||||
type: SupportedEntry.Milestone;
|
type: SupportedEntry.Milestone;
|
||||||
id: EntryId;
|
|
||||||
index: number;
|
|
||||||
title: string;
|
|
||||||
cue: string;
|
cue: string;
|
||||||
colour: string;
|
|
||||||
parent: MaybeString;
|
parent: MaybeString;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
|
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 { data, rundownId } = useFlatRundown();
|
||||||
const [results, setResults] = useState<FilterableEntry[]>([]);
|
const { data: customFields } = useCustomFields();
|
||||||
const [error, setError] = useState<MaybeString>(null);
|
|
||||||
const lastSearchString = useRef('');
|
|
||||||
|
|
||||||
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
|
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
|
||||||
|
|
||||||
/** Filters the rundown to a given evaluation */
|
/** The filters offered to the user: fixed fields plus whatever the project defines */
|
||||||
const find = useCallback(
|
const filters = useMemo<FinderFilter[]>(() => {
|
||||||
(event: ChangeEvent<HTMLInputElement>) => {
|
const customFilters = Object.entries(customFields)
|
||||||
if (!data || data.length === 0) {
|
.filter(([_key, field]) => field.type === 'text')
|
||||||
setError('No data');
|
.map(([key, field]) => ({ key, label: field.label || key }));
|
||||||
return;
|
return [...staticFilters, ...customFilters];
|
||||||
}
|
}, [customFields]);
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (event.target.value === '') {
|
const { results, error, total } = useMemo(() => {
|
||||||
setResults([]);
|
const empty = { results: [] as FilterableEntry[], error: null, total: 0 };
|
||||||
return;
|
|
||||||
|
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();
|
let eventIndex = 1;
|
||||||
lastSearchString.current = searchValue;
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const entry = data[i];
|
||||||
if (searchValue.startsWith('index ')) {
|
if (!isOntimeEvent(entry)) {
|
||||||
const searchString = searchValue.slice('index '.length).trim();
|
continue;
|
||||||
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' };
|
|
||||||
}
|
}
|
||||||
|
if (eventIndex === searchIndex) {
|
||||||
// indexes exposed to the UI are 1-based
|
return {
|
||||||
let eventIndex = 1;
|
error: null,
|
||||||
const results: FilterableEvent[] = [];
|
total: 1,
|
||||||
for (let i = 0; i < data.length; i++) {
|
results: [
|
||||||
const event = data[i];
|
{
|
||||||
if (isOntimeEvent(event)) {
|
|
||||||
if (eventIndex === searchIndex) {
|
|
||||||
results.push({
|
|
||||||
type: SupportedEntry.Event,
|
type: SupportedEntry.Event,
|
||||||
id: event.id,
|
id: entry.id,
|
||||||
index: i,
|
index: i,
|
||||||
eventIndex,
|
eventIndex,
|
||||||
title: event.title,
|
title: entry.title,
|
||||||
cue: event.cue,
|
cue: entry.cue,
|
||||||
colour: event.colour,
|
colour: entry.colour,
|
||||||
parent: event.parent,
|
parent: entry.parent,
|
||||||
} satisfies FilterableEvent);
|
match: null,
|
||||||
break;
|
} satisfies FilterableEvent,
|
||||||
}
|
],
|
||||||
eventIndex++;
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
eventIndex++;
|
||||||
return { results, error: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns maxResults of entries which carry a cue and match the cue field */
|
return empty;
|
||||||
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[] = [];
|
|
||||||
|
|
||||||
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;
|
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*/
|
return { results, error: null, total };
|
||||||
function searchByTitle(searchString: string) {
|
}
|
||||||
// indexes exposed to the UI are 1-based
|
}, [data, customFields, filters, searchValue]);
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
const select = useCallback(
|
const select = useCallback(
|
||||||
(selectedEvent: FilterableEntry) => {
|
(selectedEvent: FilterableEntry) => {
|
||||||
@@ -235,15 +289,11 @@ export default function useFinder() {
|
|||||||
[selectAndRevealEntry],
|
[selectAndRevealEntry],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** clear results when source data changes */
|
return { select, results, error, total, filters };
|
||||||
useEffect(() => {
|
}
|
||||||
setResults([]);
|
|
||||||
setError(null);
|
/** Replaces any filter prefix in the current value, keeping whatever the user already typed */
|
||||||
// fake a submit event to re-run the search
|
export function applyFilter(currentValue: string, filters: FinderFilter[], filterKey: string): string {
|
||||||
if (lastSearchString.current) {
|
const { searchString } = parseQuery(currentValue.trim().toLowerCase(), filters);
|
||||||
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
|
return searchString ? `${filterKey} ${searchString}` : `${filterKey} `;
|
||||||
}
|
|
||||||
}, [data, find]);
|
|
||||||
|
|
||||||
return { find, select, results, error };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -356,6 +356,112 @@ test('Finder searches milestones by cue', async ({ page }) => {
|
|||||||
await expect(page.getByTestId('finder-result')).toHaveCount(1);
|
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 }) => {
|
test('Open settings', async ({ page }) => {
|
||||||
await page.goto('/editor');
|
await page.goto('/editor');
|
||||||
await expect(page.getByTestId('editor-container')).toBeVisible();
|
await expect(page.getByTestId('editor-container')).toBeVisible();
|
||||||
|
|||||||
Reference in New Issue
Block a user