mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 12:53:32 +00:00
b946b245c2
* feat: create the first event from the cuesheet * chore: cleanup css * test: cuesheet beckground edit from empty state * feat: only show +event button when user is in edit mode and have full write perms
405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
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<ExtendedEntry>[];
|
|
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<TableVirtuosoHandle | null>(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 (
|
|
<HeaderComponent
|
|
key={headerGroup.id}
|
|
cuesheetMode={cuesheetMode}
|
|
headerGroup={headerGroup}
|
|
hideIndexColumn={hideIndexColumn}
|
|
/>
|
|
);
|
|
});
|
|
}, [cuesheetMode, hideIndexColumn, table]);
|
|
|
|
const isLoading = !flatRundown || status === 'pending';
|
|
|
|
if (isLoading) {
|
|
return <EmptyPage text='Loading...' />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<CuesheetTableHeaderToolbar
|
|
columns={allLeafColumns}
|
|
optionsStore={optionsStore}
|
|
handleResetResizing={resetColumnResizing}
|
|
handleResetReordering={resetColumnOrder}
|
|
handleClearToggles={setAllVisible}
|
|
insertElement={insertElement}
|
|
modeControls={
|
|
tableRoot === 'cuesheet'
|
|
? {
|
|
cuesheetMode,
|
|
setCuesheetMode,
|
|
isCurrentRundown,
|
|
}
|
|
: undefined
|
|
}
|
|
showShare={tableRoot === 'cuesheet'}
|
|
/>
|
|
<TableVirtuoso
|
|
ref={virtuosoRef}
|
|
data={flatRundown}
|
|
context={virtuosoContext}
|
|
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
|
|
computeItemKey={computeItemKey}
|
|
increaseViewportBy={{ top: 100, bottom: 200 }}
|
|
components={virtuosoComponents}
|
|
fixedHeaderContent={fixedHeaderContent}
|
|
/>
|
|
|
|
<TableMenu />
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface CuesheetVirtuosoContext {
|
|
columnSizeVars: { [key: string]: number };
|
|
cursor: string | null;
|
|
listeners: ReturnType<typeof useTableNav>['listeners'];
|
|
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
|
|
table: Table<ExtendedEntry>;
|
|
handleAddNew?: (type: SupportedEntry) => void;
|
|
}
|
|
|
|
const EmptyPlaceholder = memo(function EmptyPlaceholder({
|
|
context,
|
|
}: TableProps & ContextProp<CuesheetVirtuosoContext>) {
|
|
return <EmptyTableBody handleAddNew={context.handleAddNew} />;
|
|
});
|
|
|
|
const CuesheetTableElement = memo(function CuesheetTableElement({
|
|
style: injectedStyles,
|
|
context,
|
|
...virtuosoProps
|
|
}: TableProps & ContextProp<CuesheetVirtuosoContext>) {
|
|
return (
|
|
<table
|
|
className={style.cuesheet}
|
|
id='cuesheet'
|
|
style={{ ...injectedStyles, ...context.columnSizeVars }}
|
|
{...context.listeners}
|
|
{...virtuosoProps}
|
|
/>
|
|
);
|
|
});
|
|
|
|
const CuesheetTableHead = memo(function CuesheetTableHead({
|
|
context: _context,
|
|
className: _className,
|
|
...virtuosoProps
|
|
}: ComponentProps<'thead'> & ContextProp<CuesheetVirtuosoContext>) {
|
|
return <thead className={style.tableHeader} {...virtuosoProps} />;
|
|
});
|
|
|
|
const CuesheetTableRow = memo(function CuesheetTableRow({
|
|
item: _item,
|
|
style: injectedStyles,
|
|
context,
|
|
...virtuosoProps
|
|
}: ItemProps<ExtendedEntry> & ContextProp<CuesheetVirtuosoContext>) {
|
|
// 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 (
|
|
<GroupRow
|
|
key={key}
|
|
groupId={entry.id}
|
|
colour={entry.colour}
|
|
rowId={row.id}
|
|
rowIndex={row.index}
|
|
table={context.table}
|
|
injectedStyles={injectedStyles}
|
|
hasCursor={hasCursor}
|
|
{...virtuosoProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (isOntimeDelay(entry)) {
|
|
return (
|
|
<DelayRow
|
|
key={key}
|
|
duration={entry.duration}
|
|
injectedStyles={injectedStyles}
|
|
hasCursor={hasCursor}
|
|
{...virtuosoProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (isOntimeMilestone(entry)) {
|
|
return (
|
|
<MilestoneRow
|
|
key={key}
|
|
entryId={entry.id}
|
|
isPast={entry.isPast}
|
|
parentBgColour={entry.groupColour}
|
|
parentId={entry.parent}
|
|
colour={entry.colour}
|
|
rowId={row.id}
|
|
rowIndex={rowIndex}
|
|
table={context.table}
|
|
injectedStyles={injectedStyles}
|
|
hasCursor={hasCursor}
|
|
{...virtuosoProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<EventRow
|
|
key={row.id}
|
|
id={entry.id}
|
|
eventIndex={entry.eventIndex}
|
|
colour={entry.colour}
|
|
isFirstAfterGroup={entry.isFirstAfterGroup}
|
|
isLoaded={entry.isLoaded}
|
|
isPast={entry.isPast}
|
|
groupColour={entry.groupColour}
|
|
flag={entry.flag}
|
|
skip={entry.skip}
|
|
parent={entry.parent}
|
|
rowId={row.id}
|
|
rowIndex={rowIndex}
|
|
table={context.table}
|
|
injectedStyles={injectedStyles}
|
|
hasCursor={hasCursor}
|
|
{...virtuosoProps}
|
|
/>
|
|
);
|
|
});
|
|
|
|
const virtuosoComponents: TableComponents<ExtendedEntry, CuesheetVirtuosoContext> = {
|
|
EmptyPlaceholder,
|
|
Table: CuesheetTableElement,
|
|
TableHead: CuesheetTableHead,
|
|
TableRow: CuesheetTableRow,
|
|
};
|