import { useTableNav } from '@table-nav/react'; import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { ContextProp, ItemProps, TableComponents, TableProps, TableVirtuoso, TableVirtuosoHandle, } from 'react-virtuoso'; import EmptyPage from '../../../common/components/state/EmptyPage'; import EmptyTableBody from '../../../common/components/state/EmptyTableBody'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import type { RundownSource } from '../../../common/hooks-query/useScopedRundown'; import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options'; import { useEventSelection } from '../../../features/rundown/useEventSelection'; import { AppMode } from '../../../ontimeConfig'; import { usePersistedCuesheetOptions } from '../cuesheet.options'; import { useCuesheetPermissions } from '../useTablePermissions'; import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader'; import DelayRow from './cuesheet-table-elements/DelayRow'; import EventRow from './cuesheet-table-elements/EventRow'; import GroupRow from './cuesheet-table-elements/GroupRow'; import MilestoneRow from './cuesheet-table-elements/MilestoneRow'; import TableMenu from './cuesheet-table-menu/TableMenu'; import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar'; import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager'; import style from './CuesheetTable.module.scss'; type CuesheetTableBaseProps = { columns: ColumnDef[]; cuesheetMode: AppMode; source: RundownSource; insertElement?: ReactNode; }; type EditorCuesheetTableProps = CuesheetTableBaseProps & { tableRoot: 'editor'; setCuesheetMode?: undefined; isCurrentRundown?: undefined; }; type ViewCuesheetTableProps = CuesheetTableBaseProps & { tableRoot: 'cuesheet'; setCuesheetMode: (mode: AppMode) => void; isCurrentRundown?: boolean; }; type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps; export default function CuesheetTable({ columns, cuesheetMode, source, tableRoot, setCuesheetMode, isCurrentRundown, insertElement, }: CuesheetTableProps) { const { flatRundown, status, selectedEventId } = source; const { updateEntry, updateTimer, addEntry } = useEntryActionsContext(); const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit; const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions; const optionsStore = useOptions(); const { showDelayedTimes, hideTableSeconds, hideIndexColumn } = optionsStore; const cursor = useEventSelection((state) => state.cursor); const setScrollHandler = useEventSelection((state) => state.setScrollHandler); const virtuosoRef = useRef(null); const { listeners } = useTableNav(); const meta = useMemo( () => ({ handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => { // check if value is the same const event = flatRundown[rowIndex]; if (!event) { return; } // skip if there is no value change const key = accessor as keyof OntimeEntry; const previousValue = event[key]; if (previousValue === payload) { return; } if (isCustom) { updateEntry({ id: event.id, custom: { [accessor]: payload } }); return; } updateEntry({ id: event.id, [accessor]: payload }); }, handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => { // the timer element already contains logic to avoid submitting a unchanged value updateTimer(eventId, field, payload, true); }, options: { showDelayedTimes, hideTableSeconds, cuesheetMode, hideIndexColumn, }, }), [cuesheetMode, flatRundown, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer], ); const { columnOrder, resetColumnOrder } = useColumnOrder(columns, tableRoot); const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot); const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot); const table = useReactTable({ data: flatRundown, columns, columnResizeMode: 'onChange', state: { columnOrder, columnVisibility, columnSizing, }, onColumnVisibilityChange: setColumnVisibility, onColumnSizingChange: setColumnSizing, getCoreRowModel: getCoreRowModel(), meta, }); const setAllVisible = useCallback(() => { table.toggleAllColumnsVisible(true); }, [table]); const resetColumnResizing = useCallback(() => { setColumnSizing({}); }, [setColumnSizing]); // in Run mode, follow the current event useEffect(() => { if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) { return; } const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId); if (eventIndex === -1) { return; } virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 }); }, [cuesheetMode, flatRundown, selectedEventId]); // Provide an imperative scroll handler for explicit jumps (finder/keyboard) useEffect(() => { const handler = (entryId: string) => { if (virtuosoRef.current === null) { return; } const eventIndex = flatRundown.findIndex((event) => event.id === entryId); if (eventIndex === -1) { return; } virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 }); }; setScrollHandler(handler); return () => { setScrollHandler(null); }; }, [flatRundown, setScrollHandler]); /** * To improve performance on resizing, we memoise the column sizes * and pass them as CSS variables to the table container. */ const columnSizeVars = useMemo(() => { const headers = table.getFlatHeaders(); const colSizes: { [key: string]: number } = {}; for (let i = 0; i < headers.length; i++) { const header = headers[i]; if (!header) continue; colSizes[`--header-${header.id}-size`] = header.getSize(); colSizes[`--col-${header.column.id}-size`] = header.column.getSize(); } return colSizes; // eslint-disable-next-line react-compiler/react-compiler -- unfortunately this is what we need // eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation }, [table.getState().columnSizingInfo, table.getState().columnSizing]); const allLeafColumns = table.getAllLeafColumns(); const { rows } = table.getRowModel(); const virtuosoContext = useMemo( () => ({ columnSizeVars, cursor, listeners, rows, table, handleAddNew: canCreateEntries ? (type: SupportedEntry) => addEntry({ type }) : undefined, }), [columnSizeVars, cursor, listeners, rows, table, addEntry, canCreateEntries], ); const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []); const fixedHeaderContent = useCallback(() => { return table.getHeaderGroups().map((headerGroup) => { const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn ? CuesheetHeader : SortableCuesheetHeader; // if the table is being resized, we render non-sortable headers to avoid performance issues return ( ); }); }, [cuesheetMode, hideIndexColumn, table]); const isLoading = !flatRundown || status === 'pending'; if (isLoading) { return ; } return ( <> ); } interface CuesheetVirtuosoContext { columnSizeVars: { [key: string]: number }; cursor: string | null; listeners: ReturnType['listeners']; rows: ReturnType['getRowModel']>['rows']; table: Table; handleAddNew?: (type: SupportedEntry) => void; } const EmptyPlaceholder = memo(function EmptyPlaceholder({ context, }: TableProps & ContextProp) { return ; }); const CuesheetTableElement = memo(function CuesheetTableElement({ style: injectedStyles, context, ...virtuosoProps }: TableProps & ContextProp) { return ( ); }); const CuesheetTableHead = memo(function CuesheetTableHead({ context: _context, className: _className, ...virtuosoProps }: ComponentProps<'thead'> & ContextProp) { return ; }); const CuesheetTableRow = memo(function CuesheetTableRow({ item: _item, style: injectedStyles, context, ...virtuosoProps }: ItemProps & ContextProp) { // eslint-disable-next-line react/destructuring-assignment const rowIndex = virtuosoProps['data-index']; const row = context.rows[rowIndex]; if (!row) { return null; } const key = row.original.id; const entry = row.original; const hasCursor = entry.id === context.cursor; if (isOntimeGroup(entry)) { return ( ); } if (isOntimeDelay(entry)) { return ( ); } if (isOntimeMilestone(entry)) { return ( ); } return ( ); }); const virtuosoComponents: TableComponents = { EmptyPlaceholder, Table: CuesheetTableElement, TableHead: CuesheetTableHead, TableRow: CuesheetTableRow, };