From daddb7f7b786f810fc896f0459a7eeaa34a34e2a Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 2 Aug 2025 09:27:59 +0200 Subject: [PATCH] fix: open group when selecting from finder --- .../src/common/hooks-query/useRundown.ts | 3 +- apps/client/src/index.scss | 13 + .../client/src/views/editor/finder/Finder.tsx | 8 +- .../src/views/editor/finder/useFinder.tsx | 344 ++++++++++-------- 4 files changed, 208 insertions(+), 160 deletions(-) diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts index d3ae648a7..c344b83c3 100644 --- a/apps/client/src/common/hooks-query/useRundown.ts +++ b/apps/client/src/common/hooks-query/useRundown.ts @@ -56,6 +56,7 @@ export function useFlatRundown() { }, [data.entries, data.flatOrder, data.revision, prevRevision]); // TODO: should we have a project id field? + // TODO(v4): cleanup as part of load multiple rundowns // invalidate current version if project changes useEffect(() => { if (projectData?.title !== loadedProject.current) { @@ -64,7 +65,7 @@ export function useFlatRundown() { } }, [projectData]); - return { data: flatRundown, status }; + return { data: flatRundown, rundownId: data.id, status }; } /** diff --git a/apps/client/src/index.scss b/apps/client/src/index.scss index f44e22265..9c58b9084 100644 --- a/apps/client/src/index.scss +++ b/apps/client/src/index.scss @@ -114,6 +114,19 @@ table { word-break: break-word; } +// reset list styles +ul, +ol { + list-style: none; + margin: 0; + padding: 0; +} + +li { + margin: 0; + padding: 0; +} + // remove buttons in number inputs /* WebKit and Blink */ input::-webkit-outer-spin-button, diff --git a/apps/client/src/views/editor/finder/Finder.tsx b/apps/client/src/views/editor/finder/Finder.tsx index 851869cf0..3d39fbe96 100644 --- a/apps/client/src/views/editor/finder/Finder.tsx +++ b/apps/client/src/views/editor/finder/Finder.tsx @@ -4,7 +4,6 @@ import { SupportedEntry } from 'ontime-types'; import Input from '../../../common/components/input/input/Input'; import Modal from '../../../common/components/modal/Modal'; -import { useEventSelection } from '../../../features/rundown/useEventSelection'; import useFinder from './useFinder'; @@ -16,10 +15,9 @@ interface FinderProps { } export default function Finder({ isOpen, onClose }: FinderProps) { - const { find, results, error } = useFinder(); + const { find, select, results, error } = useFinder(); const [selected, setSelected] = useState(0); - const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents); const debouncedFind = useDebouncedCallback(find, 100); const navigate = (event: KeyboardEvent) => { @@ -34,13 +32,15 @@ export default function Finder({ isOpen, onClose }: FinderProps) { setSelected((prev) => (prev - 1 + results.length) % results.length); } if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); submit(); } }; const submit = () => { const selectedEvent = results[selected]; - setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' }); + select(selectedEvent); onClose(); }; diff --git a/apps/client/src/views/editor/finder/useFinder.tsx b/apps/client/src/views/editor/finder/useFinder.tsx index 1a0fada7a..a857fd97b 100644 --- a/apps/client/src/views/editor/finder/useFinder.tsx +++ b/apps/client/src/views/editor/finder/useFinder.tsx @@ -1,7 +1,9 @@ -import { ChangeEvent, useEffect, useRef, useState } from 'react'; -import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEntry } from 'ontime-types'; +import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { useSessionStorage } from '@mantine/hooks'; +import { EntryId, isOntimeBlock, isOntimeEvent, MaybeString, SupportedEntry } from 'ontime-types'; import { useFlatRundown } from '../../../common/hooks-query/useRundown'; +import { useEventSelection } from '../../../features/rundown/useEventSelection'; const maxResults = 12; @@ -20,16 +22,198 @@ type FilterableEvent = { title: string; cue: string; colour: string; + parent: MaybeString; }; type FilterableEntry = FilterableBlock | FilterableEvent; export default function useFinder() { - const { data } = useFlatRundown(); + const { data, rundownId } = useFlatRundown(); const [results, setResults] = useState([]); const [error, setError] = useState(null); const lastSearchString = useRef(''); + const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents); + + const [collapsedGroups, setCollapsedGroups] = useSessionStorage({ + // we ensure that this is unique to the rundown + key: `rundown.${rundownId}-editor-collapsed-groups`, + defaultValue: [], + }); + + /** Filters the rundown to a given evaluation */ + const find = useCallback( + (event: ChangeEvent) => { + if (!data || data.length === 0) { + setError('No data'); + return; + } + setError(null); + + if (event.target.value === '') { + setResults([]); + return; + } + + const searchValue = event.target.value.toLowerCase(); + lastSearchString.current = searchValue; + + if (searchValue.startsWith('index ')) { + const searchString = searchValue.replace('index ', '').trim(); + const { results, error } = searchByIndex(searchString); + setResults(results); + setError(error); + return; + } + + if (searchValue.startsWith('cue ')) { + const searchString = searchValue.replace('cue ', '').trim(); + const { results, error } = searchByCue(searchString); + setResults(results); + setError(error); + return; + } + + const searchString = searchValue.replace('title ', '').trim(); + 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 event = data[i]; + if (isOntimeEvent(event)) { + if (event.title.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++; + } + if (isOntimeBlock(event)) { + if (event.title.toLowerCase().includes(searchString)) { + remaining--; + results.push({ + type: SupportedEntry.Block, + id: event.id, + index: i, + title: event.title, + } satisfies FilterableBlock); + } + } + } + return { results, error: null }; + } + }, + [data], + ); + + const select = useCallback( + (selectedEvent: FilterableEntry) => { + // First expand the parent group if this is an event inside a group + if (selectedEvent.type === SupportedEntry.Event && selectedEvent.parent !== null) { + // Try direct state update instead of using callback + const currentGroups = [...new Set(collapsedGroups)]; + const newGroups = currentGroups.filter((id) => id !== selectedEvent.parent); + // Force a direct update + setCollapsedGroups(newGroups); + } + + // Then select the event + setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' }); + }, + [collapsedGroups, setCollapsedGroups, setSelectedEvents], + ); + /** clear results when source data changes */ useEffect(() => { setResults([]); @@ -38,157 +222,7 @@ export default function useFinder() { if (lastSearchString.current) { find({ target: { value: lastSearchString.current } } as ChangeEvent); } - }, [data]); + }, [data, find]); - /** Returns a single item with a matching index */ - const 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, - } satisfies FilterableEvent); - break; - } - eventIndex++; - } - } - - return { results, error: null }; - }; - - /** Returns maxResults of OntimeEvents that match the cue field */ - const 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, - } satisfies FilterableEvent); - } - eventIndex++; - } - } - return { results, error: null }; - }; - - /** Returns maxResults of OntimeEvents that match the title field*/ - const 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 event = data[i]; - if (isOntimeEvent(event)) { - if (event.title.toLowerCase().includes(searchString)) { - remaining--; - results.push({ - type: SupportedEntry.Event, - id: event.id, - index: i, - eventIndex, - title: event.title, - cue: event.cue, - colour: event.colour, - } satisfies FilterableEvent); - } - eventIndex++; - } - if (isOntimeBlock(event)) { - if (event.title.toLowerCase().includes(searchString)) { - remaining--; - results.push({ - type: SupportedEntry.Block, - id: event.id, - index: i, - title: event.title, - } satisfies FilterableBlock); - } - } - } - return { results, error: null }; - }; - - /** Filters the rundown to a given evaluation */ - const find = (event: ChangeEvent) => { - if (!data || data.length === 0) { - setError('No data'); - return; - } - setError(null); - - if (event.target.value === '') { - setResults([]); - return; - } - - const searchValue = event.target.value.toLowerCase(); - lastSearchString.current = searchValue; - - if (searchValue.startsWith('index ')) { - const searchString = searchValue.replace('index ', '').trim(); - const { results, error } = searchByIndex(searchString); - setResults(results); - setError(error); - return; - } - - if (searchValue.startsWith('cue ')) { - const searchString = searchValue.replace('cue ', '').trim(); - const { results, error } = searchByCue(searchString); - setResults(results); - setError(error); - return; - } - - const searchString = searchValue.replace('title ', '').trim(); - const { results, error } = searchByTitle(searchString); - setResults(results); - setError(error); - }; - - return { find, results, error }; + return { find, select, results, error }; }