Compare commits

...

7 Commits

Author SHA1 Message Date
Carlos Valente 8e7ab54d39 refactor: defer table while loading 2025-03-04 22:01:21 +01:00
arc-alex 4a16378786 refactor: pad block row with extra columns 2025-03-04 22:01:21 +01:00
Shobhit Nagpal e5b30770ce feat: add table navigation to cuesheet table (#1483)
* refactor: cuesheet table body
* feat: add table navigation
2025-03-04 22:01:21 +01:00
Carlos Valente 8465b04c89 bump version to 3.14.0 2025-03-04 21:54:29 +01:00
Carlos Valente 718a6ad2b7 refactor: improve editor overflow 2025-03-04 21:54:29 +01:00
Carlos Valente 6ca4bfc363 refactor: allow overriding styles of cuesheet 2025-03-03 15:58:52 +01:00
Carlos Valente 4859026989 refactor: op cannot subscribe to image fields 2025-03-03 15:58:52 +01:00
29 changed files with 339 additions and 151 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.13.1", "version": "3.14.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.13.1", "version": "3.14.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -15,6 +15,7 @@
"@mantine/hooks": "^7.13.3", "@mantine/hooks": "^7.13.3",
"@react-icons/all-files": "^4.1.0", "@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.43.0", "@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7", "@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.20.5", "@tanstack/react-table": "^8.20.5",
+8 -1
View File
@@ -158,7 +158,14 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} /> <Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<PCuesheet />} /> <Route path='/cuesheet' element={<PCuesheet />} />
<Route path='/op' element={<POperator />} /> <Route
path='/op'
element={
<ViewLoader>
<POperator />
</ViewLoader>
}
/>
{/*/!* Protected Routes - Elements *!/*/} {/*/!* Protected Routes - Elements *!/*/}
<Route <Route
@@ -17,6 +17,7 @@ export default function useReactiveTextInput(
submitOnCtrlEnter?: boolean; submitOnCtrlEnter?: boolean;
onCancelUpdate?: () => void; onCancelUpdate?: () => void;
allowSubmitSameValue?: boolean; allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean;
}, },
): UseReactiveTextInputReturn { ): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText); const [text, setText] = useState<string>(initialText);
@@ -58,7 +59,14 @@ export default function useReactiveTextInput(
setText(cleanVal); setText(cleanVal);
} }
} }
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
setTimeout(() => {
if (options?.allowKeyboardNavigation) {
ref.current?.parentElement?.focus(); // Focus on parent element to continue keyboard navigation
} else {
ref.current?.blur();
}
}); // Immediate timeout to ensure text is set before blurring
}, },
[initialText, options, ref, submitCallback], [initialText, options, ref, submitCallback],
); );
@@ -70,12 +78,25 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
// No need to update if it hasn't changed // No need to update if it hasn't changed
setText(initialText); setText(initialText);
// force the text to be the initial value
if (ref.current) {
ref.current.value = initialText;
}
options?.onCancelUpdate?.(); options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
}, [initialText, options, ref]); }, [initialText, options, ref]);
const keyHandler = useMemo(() => { const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]]; const hotKeys: HotkeyItem[] = [
[
'Escape',
(event) => {
event.preventDefault();
handleEscape();
},
{ preventDefault: true },
],
];
if (options?.submitOnEnter) { if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => handleSubmit(text)]); hotKeys.push(['Enter', () => handleSubmit(text)]);
@@ -84,7 +105,22 @@ export default function useReactiveTextInput(
if (options?.submitOnCtrlEnter) { if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => handleSubmit(text)]); hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
} }
return getHotkeyHandler(hotKeys);
const hotKeyHandler = getHotkeyHandler(hotKeys);
return (event: KeyboardEvent<HTMLElement>) => {
// allow moving in input field with arrow keys
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown'
) {
event.stopPropagation();
}
hotKeyHandler(event);
};
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]); }, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
return { return {
@@ -126,9 +126,9 @@ function MultiOption(props: EditFormMultiOptionProps) {
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])} onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
> >
{Object.values(paramField.values).map((option) => { {Object.values(paramField.values).map((option) => {
const { value, label } = option; const { value, label, colour } = option;
return ( return (
<MenuItemOption value={value} key={value}> <MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
{label} {label}
</MenuItemOption> </MenuItemOption>
); );
@@ -1,6 +1,6 @@
import { CustomFields } from 'ontime-types'; import { CustomFields } from 'ontime-types';
import type { ParamField } from './types'; import type { MultiselectOptions, ParamField } from './types';
export const makeOptionsFromCustomFields = ( export const makeOptionsFromCustomFields = (
customFields: CustomFields, customFields: CustomFields,
@@ -18,6 +18,17 @@ export const makeOptionsFromCustomFields = (
return options; return options;
}; };
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
const options: MultiselectOptions = {};
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[key] = { value: key, label: value.label, colour: value.colour };
}
return options;
}
export const getTimeOption = (timeFormat: string): ParamField => { export const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`; const placeholder = `${timeFormat} (default)`;
return { return {
@@ -12,9 +12,11 @@ type OptionsField = {
defaultValue?: string; defaultValue?: string;
}; };
export type MultiselectOption = { value: string; label: string; colour: string };
export type MultiselectOptions = Record<string, MultiselectOption>;
type MultiOptionsField = { type MultiOptionsField = {
type: 'multi-option'; type: 'multi-option';
values: Record<string, { value: string; label: string; colour: string }>; values: MultiselectOptions;
defaultValue?: string; defaultValue?: string;
}; };
@@ -0,0 +1,12 @@
/**
* Allows lazy evaluation and caching of a functions result
*/
export function lazyEvaluate<T>(fn: () => T): () => T {
let result: T | undefined;
return function () {
if (result === undefined) {
result = fn();
}
return result;
};
}
@@ -116,7 +116,7 @@ export default function Operator() {
// subscriptions is a MultiSelect and may have multiple values // subscriptions is a MultiSelect and may have multiple values
const subscriptions = searchParams.getAll('subscribe').filter((value) => Object.hasOwn(customFields, value)); const subscriptions = searchParams.getAll('subscribe').filter((value) => Object.hasOwn(customFields, value));
const canEdit = shouldEdit && subscriptions; const canEdit = shouldEdit && subscriptions.length;
const main = searchParams.get('main') as keyof TitleFields | null; const main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary'); const secondary = searchParams.get('secondary');
@@ -2,6 +2,7 @@ import { CustomFields } from 'ontime-types';
import { import {
getTimeOption, getTimeOption,
makeCustomFieldSelectOptions,
makeOptionsFromCustomFields, makeOptionsFromCustomFields,
OptionTitle, OptionTitle,
} from '../../common/components/view-params-editor/constants'; } from '../../common/components/view-params-editor/constants';
@@ -9,13 +10,7 @@ import { ViewOption } from '../../common/components/view-params-editor/types';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => { export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' }); const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = makeCustomFieldSelectOptions(customFields);
const customFieldSelect = Object.entries(customFields).reduce<
Record<string, { value: string; label: string; colour: string }>
>((acc, [key, field]) => {
acc[key] = { value: key, label: field.label, colour: field.colour };
return acc;
}, {});
return [ return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] }, { title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
@@ -46,6 +41,13 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
type: 'multi-option', type: 'multi-option',
values: customFieldSelect, values: customFieldSelect,
}, },
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an highlighted custom field by long pressing',
type: 'boolean',
defaultValue: false,
},
], ],
}, },
{ {
@@ -59,13 +61,6 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
type: 'boolean', type: 'boolean',
defaultValue: false, defaultValue: false,
}, },
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
], ],
}, },
]; ];
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { isOntimeEvent, OntimeEvent } from 'ontime-types'; import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown'; import useRundown from '../../../common/hooks-query/useRundown';
import { cx } from '../../../common/utils/styleUtils';
import EventEditor from './EventEditor'; import EventEditor from './EventEditor';
@@ -37,7 +38,7 @@ export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
} }
return ( return (
<div className={style.eventEditor} data-testid='editor-container'> <div className={cx([style.eventEditor, style.inModal])} data-testid='editor-container'>
<EventEditor event={event} /> <EventEditor event={event} />
</div> </div>
); );
@@ -1,8 +1,12 @@
.eventEditor { .eventEditor {
max-height: 80vh; max-height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-x: auto; overflow-x: auto;
&.inModal {
max-height: 80vh;
}
} }
.content { .content {
@@ -23,8 +23,8 @@ import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() { export default function CuesheetPage() {
// TODO: can we use the normalised rundown for the table? // TODO: can we use the normalised rundown for the table?
const { data: flatRundown } = useFlatRundown(); const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: customFields } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable: true }); const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable: true });
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure(); const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isEventEditorOpen, onOpen: onEventEditorOpen, onClose: onEventEditorClose } = useDisclosure(); const { isOpen: isEventEditorOpen, onOpen: onEventEditorOpen, onClose: onEventEditorClose } = useDisclosure();
@@ -50,7 +50,7 @@ export default function CuesheetPage() {
[onEventEditorClose, onEventEditorOpen], [onEventEditorClose, onEventEditorOpen],
); );
if (!customFields || !flatRundown) { if (!customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending') {
return <EmptyPage text='Loading...' />; return <EmptyPage text='Loading...' />;
} }
@@ -28,6 +28,11 @@ $table-header-font-size: calc(1rem - 2px);
position: relative; position: relative;
@include ellipsis-overflow; @include ellipsis-overflow;
} }
th:focus,
td:focus {
outline: 2px solid $ui-white;
}
} }
.tableHeader, .tableHeader,
@@ -1,27 +1,14 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import Color from 'color'; import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown, OntimeRundownEntry, TimeField } from 'ontime-types';
import {
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
MaybeString,
OntimeEvent,
OntimeRundown,
OntimeRundownEntry,
TimeField,
} from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import useFollowComponent from '../../../common/hooks/useFollowComponent'; import useFollowComponent from '../../../common/hooks/useFollowComponent';
import { useSelectedEventId } from '../../../common/hooks/useSocket';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { useCuesheetOptions } from '../cuesheet.options'; import { useCuesheetOptions } from '../cuesheet.options';
import BlockRow from './cuesheet-table-elements/BlockRow'; import CuesheetBody from './cuesheet-table-elements/CuesheetBody';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader'; import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu'; import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings'; import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import useColumnManager from './useColumnManager'; import useColumnManager from './useColumnManager';
@@ -38,8 +25,7 @@ export default function CuesheetTable(props: CuesheetTableProps) {
const { data, columns, showModal } = props; const { data, columns, showModal } = props;
const { updateEvent, updateTimer } = useEventAction(); const { updateEvent, updateTimer } = useEventAction();
const { selectedEventId } = useSelectedEventId(); const { followSelected, showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
const { followSelected, hideDelays, hidePast, showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } = const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
useColumnManager(columns); useColumnManager(columns);
@@ -47,6 +33,8 @@ export default function CuesheetTable(props: CuesheetTableProps) {
const tableContainerRef = useRef<HTMLDivElement | null>(null); const tableContainerRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected }); useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
const { listeners } = useTableNav();
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
@@ -63,6 +51,7 @@ export default function CuesheetTable(props: CuesheetTableProps) {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => { handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
// check if value is the same // check if value is the same
const event = data[rowIndex]; const event = data[rowIndex];
if (!event || !isOntimeEvent(event)) { if (!event || !isOntimeEvent(event)) {
return; return;
} }
@@ -104,10 +93,6 @@ export default function CuesheetTable(props: CuesheetTableProps) {
const rowModel = table.getRowModel(); const rowModel = table.getRowModel();
const allLeafColumns = table.getAllLeafColumns(); const allLeafColumns = table.getAllLeafColumns();
let eventIndex = 0;
// for the first event, it will be past if there is something selected
let isPast = Boolean(selectedEventId);
return ( return (
<> <>
<CuesheetTableSettings <CuesheetTableSettings
@@ -117,74 +102,9 @@ export default function CuesheetTable(props: CuesheetTableProps) {
handleClearToggles={setAllVisible} handleClearToggles={setAllVisible}
/> />
<div ref={tableContainerRef} className={style.cuesheetContainer}> <div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet} id='cuesheet'> <table className={style.cuesheet} id='cuesheet' {...listeners}>
<CuesheetHeader headerGroups={headerGroups} /> <CuesheetHeader headerGroups={headerGroups} />
<tbody> <CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} columnSizing={columnSizing} />
{rowModel.rows.map((row, index) => {
const key = row.original.id;
const isSelected = selectedEventId === key;
const entry = row.original;
if (isSelected) {
isPast = false;
}
if (isOntimeBlock(entry)) {
return <BlockRow key={key} title={entry.title} hidePast={isPast && hidePast} />;
}
if (isOntimeDelay(entry)) {
if (isPast && hidePast) {
return null;
}
const delayVal = entry.duration;
if (hideDelays || delayVal === 0) {
return null;
}
return <DelayRow key={key} duration={delayVal} />;
}
if (isOntimeEvent(entry)) {
eventIndex++;
const isSelected = key === selectedEventId;
if (isPast && hidePast) {
return null;
}
let rowBgColour: string | undefined;
if (isSelected) {
rowBgColour = '#D20300'; // $red-700
} else if (entry.colour) {
try {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = Color(getAccessibleColour(entry.colour).backgroundColor);
rowBgColour = accessibleBackgroundColor.fade(0.75).hexa();
} catch (_error) {
/* we do not handle errors here */
}
}
return (
<EventRow
key={`${row.id}-${entry.revision}`}
rowId={row.id}
eventId={entry.id}
eventIndex={eventIndex}
rowIndex={index}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
skip={entry.skip}
colour={entry.colour}
rowBgColour={rowBgColour}
table={table}
columnSizing={columnSizing}
/>
);
}
// currently there is no scenario where entryType is not handled above, either way...
return null;
})}
</tbody>
</table> </table>
</div> </div>
<CuesheetTableMenu showModal={showModal} /> <CuesheetTableMenu showModal={showModal} />
@@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, useRef } from 'react';
import { useCurrentBlockId } from '../../../../common/hooks/useSocket'; import { useCurrentBlockId } from '../../../../common/hooks/useSocket';
@@ -7,19 +7,37 @@ import style from '../CuesheetTable.module.scss';
interface BlockRowProps { interface BlockRowProps {
hidePast: boolean; hidePast: boolean;
title: string; title: string;
columnCount: number;
} }
function BlockRow(props: BlockRowProps) { function BlockRow(props: BlockRowProps) {
const { hidePast, title } = props; const { hidePast, title, columnCount } = props;
const { currentBlockId } = useCurrentBlockId(); const { currentBlockId } = useCurrentBlockId();
const firstCellRef = useRef<null | HTMLTableCellElement>(null);
if (hidePast && !currentBlockId) { if (hidePast && !currentBlockId) {
return null; return null;
} }
const paddingRows = new Array(columnCount - 1).fill(null);
return ( return (
<tr className={style.blockRow}> <tr className={style.blockRow}>
<td>{title}</td> <td tabIndex={-1} role='cell' ref={firstCellRef}>
{title}
</td>
{paddingRows.map((_value, index) => {
return (
<td
key={index}
tabIndex={-1}
role='cell'
onFocus={() => {
firstCellRef.current?.focus();
}}
/>
);
})}
</tr> </tr>
); );
} }
@@ -0,0 +1,100 @@
import { MutableRefObject } from 'react';
import { RowModel, Table } from '@tanstack/react-table';
import Color from 'color';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { useSelectedEventId } from '../../../../common/hooks/useSocket';
import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { useCuesheetOptions } from '../../../cuesheet/cuesheet.options';
import BlockRow from './BlockRow';
import DelayRow from './DelayRow';
import EventRow from './EventRow';
interface CuesheetBodyProps {
rowModel: RowModel<OntimeRundownEntry>;
selectedRef: MutableRefObject<HTMLTableRowElement | null>;
table: Table<OntimeRundownEntry>;
columnSizing: Record<string, number>;
}
export default function CuesheetBody(props: CuesheetBodyProps) {
const { rowModel, selectedRef, table, columnSizing } = props;
const { selectedEventId } = useSelectedEventId();
const { hideDelays, hidePast } = useCuesheetOptions();
const getColumnCount = lazyEvaluate(() => table.getVisibleFlatColumns().length);
let eventIndex = 0;
// for the first event, it will be past if there is something selected
let isPast = Boolean(selectedEventId);
return (
<tbody>
{rowModel.rows.map((row, index) => {
const key = row.original.id;
const isSelected = selectedEventId === key;
const entry = row.original;
if (isSelected) {
isPast = false;
}
if (isOntimeBlock(entry)) {
const columnCount = getColumnCount();
return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />;
}
if (isOntimeDelay(entry)) {
if (isPast && hidePast) {
return null;
}
const delayVal = entry.duration;
if (hideDelays || delayVal === 0) {
return null;
}
return <DelayRow key={key} duration={delayVal} />;
}
if (isOntimeEvent(entry)) {
eventIndex++;
const isSelected = key === selectedEventId;
if (isPast && hidePast) {
return null;
}
let rowBgColour: string | undefined;
if (isSelected) {
rowBgColour = '#D20300'; // $red-700
} else if (entry.colour) {
try {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = Color(getAccessibleColour(entry.colour).backgroundColor);
rowBgColour = accessibleBackgroundColor.fade(0.75).hexa();
} catch (_error) {
/* we do not handle errors here */
}
}
return (
<EventRow
key={row.id}
rowId={row.id}
event={entry}
eventIndex={eventIndex}
rowIndex={index}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
rowBgColour={rowBgColour}
table={table}
columnSizing={columnSizing}
/>
);
}
// currently there is no scenario where entryType is not handled above, either way...
return null;
})}
</tbody>
);
}
@@ -24,8 +24,12 @@ export default function CuesheetHeader(props: CuesheetHeaderProps) {
return ( return (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{showActionMenu && <th className={style.actionColumn} />} {showActionMenu && <th className={style.actionColumn} tabIndex={-1} />}
{!hideIndexColumn && <th className={style.indexColumn}>#</th>} {!hideIndexColumn && (
<th className={style.indexColumn} tabIndex={-1}>
#
</th>
)}
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}> <SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
const width = header.getSize(); const width = header.getSize();
@@ -14,7 +14,9 @@ function DelayRow(props: DelayRowProps) {
return ( return (
<tr className={style.delayRow}> <tr className={style.delayRow}>
<td>{delayTime}</td> <td tabIndex={0} role='cell'>
{delayTime}
</td>
</tr> </tr>
); );
} }
@@ -2,7 +2,7 @@ import { memo, MutableRefObject, useLayoutEffect, useRef, useState } from 'react
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal'; import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
import { flexRender, Table } from '@tanstack/react-table'; import { flexRender, Table } from '@tanstack/react-table';
import Color from 'color'; import Color from 'color';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
@@ -13,7 +13,7 @@ import style from '../CuesheetTable.module.scss';
interface EventRowProps { interface EventRowProps {
rowId: string; rowId: string;
eventId: string; event: OntimeEvent;
eventIndex: number; eventIndex: number;
rowIndex: number; rowIndex: number;
isPast?: boolean; isPast?: boolean;
@@ -26,10 +26,22 @@ interface EventRowProps {
columnSizing: Record<string, number>; columnSizing: Record<string, number>;
} }
export default memo(EventRow); export default memo(EventRow, (prevProps, nextProps) => {
return (
prevProps.rowId === nextProps.rowId &&
prevProps.event.revision === nextProps.event.revision &&
prevProps.eventIndex === nextProps.eventIndex &&
prevProps.rowIndex === nextProps.rowIndex &&
prevProps.isPast === nextProps.isPast &&
prevProps.selectedRef === nextProps.selectedRef &&
prevProps.rowBgColour === nextProps.rowBgColour &&
prevProps.table === nextProps.table &&
prevProps.columnSizing === nextProps.columnSizing
);
});
function EventRow(props: EventRowProps) { function EventRow(props: EventRowProps) {
const { rowId, eventId, eventIndex, rowIndex, isPast, selectedRef, skip, colour, rowBgColour, table } = props; const { rowId, event, eventIndex, rowIndex, isPast, selectedRef, rowBgColour, table } = props;
const { hideIndexColumn, showActionMenu } = useCuesheetOptions(); const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
const ownRef = useRef<HTMLTableRowElement>(null); const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@@ -63,23 +75,23 @@ function EventRow(props: EventRowProps) {
}; };
}, [ownRef, selectedRef]); }, [ownRef, selectedRef]);
const { color, backgroundColor } = getAccessibleColour(colour); const { color, backgroundColor } = getAccessibleColour(event.colour);
const mutedText = Color(color).fade(0.4).hexa(); const mutedText = Color(color).fade(0.4).hexa();
return ( return (
<tr <tr
className={cx([style.eventRow, skip ?? style.skip])} className={cx([style.eventRow, event.skip ?? style.skip])}
style={{ opacity: `${isPast ? '0.2' : '1'}` }} style={{ opacity: `${isPast ? '0.2' : '1'}` }}
ref={selectedRef ?? ownRef} ref={selectedRef ?? ownRef}
> >
{showActionMenu && ( {showActionMenu && (
<td className={style.actionColumn}> <td className={style.actionColumn} tabIndex={-1} role='cell'>
<IconButton <IconButton
aria-label='Options' aria-label='Options'
onClick={(event) => { onClick={(e) => {
const rect = event.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const yPos = 8 + rect.y + rect.height / 2; const yPos = 8 + rect.y + rect.height / 2;
openMenu({ x: rect.x, y: yPos }, eventId, rowIndex); openMenu({ x: rect.x, y: yPos }, event.id, rowIndex);
}} }}
> >
<IoEllipsisHorizontal /> <IoEllipsisHorizontal />
@@ -87,7 +99,7 @@ function EventRow(props: EventRowProps) {
</td> </td>
)} )}
{!hideIndexColumn && ( {!hideIndexColumn && (
<td className={style.indexColumn} style={{ backgroundColor, color: mutedText }}> <td className={style.indexColumn} style={{ backgroundColor, color: mutedText }} tabIndex={-1} role='cell'>
{eventIndex} {eventIndex}
</td> </td>
)} )}
@@ -97,7 +109,12 @@ function EventRow(props: EventRowProps) {
?.getVisibleCells() ?.getVisibleCells()
.map((cell) => { .map((cell) => {
return ( return (
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}> <td
key={cell.id}
style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}
tabIndex={-1}
role='cell'
>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</td> </td>
); );
@@ -17,6 +17,7 @@ function MultiLineCell(props: MultiLineCellProps) {
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, { const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
submitOnCtrlEnter: true, submitOnCtrlEnter: true,
allowKeyboardNavigation: true,
}); });
return ( return (
@@ -17,6 +17,7 @@ const SingleLineCell = forwardRef((props: SingleLineCellProps, inputRef) => {
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, { const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
allowSubmitSameValue, allowSubmitSameValue,
allowKeyboardNavigation: true,
submitOnEnter: true, // single line should submit on enter submitOnEnter: true, // single line should submit on enter
submitOnCtrlEnter: true, submitOnCtrlEnter: true,
onCancelUpdate: handleCancelUpdate, onCancelUpdate: handleCancelUpdate,
@@ -31,6 +32,9 @@ const SingleLineCell = forwardRef((props: SingleLineCellProps, inputRef) => {
select() { select() {
ref.current?.select(); ref.current?.select();
}, },
focusParentElement() {
ref.current?.parentElement?.focus();
},
}; };
}, [ref]); }, [ref]);
@@ -28,7 +28,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
}; };
return ( return (
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}> <th ref={setNodeRef} style={dragStyle} colSpan={colSpan} tabIndex={-1}>
<div {...attributes} {...listeners}> <div {...attributes} {...listeners}>
{children} {children}
</div> </div>
@@ -1,22 +1,34 @@
import { HTMLAttributes, memo, PropsWithChildren } from 'react'; import { forwardRef, HTMLAttributes, memo, PropsWithChildren, useImperativeHandle, useRef } from 'react';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import style from './TextLikeInput.module.scss'; import style from './TextLikeInput.module.scss';
export default memo(TextLikeInput);
interface TextLikeInputProps extends HTMLAttributes<HTMLSpanElement> { interface TextLikeInputProps extends HTMLAttributes<HTMLSpanElement> {
delayed?: boolean; delayed?: boolean;
muted?: boolean; muted?: boolean;
} }
function TextLikeInput(props: PropsWithChildren<TextLikeInputProps>) { const TextLikeInput = forwardRef((props: PropsWithChildren<TextLikeInputProps>, textRef) => {
const { delayed, muted, children, className, ...elementProps } = props; const { delayed, muted, children, className, ...elementProps } = props;
const ref = useRef<HTMLDivElement | null>(null);
const classes = cx([style.textInput, delayed && style.delayed, muted && style.muted, className]); const classes = cx([style.textInput, delayed && style.delayed, muted && style.muted, className]);
useImperativeHandle(textRef, () => {
return {
focusParentElement() {
ref.current?.parentElement?.focus();
}
}
})
return ( return (
<div className={classes} {...elementProps} tabIndex={0}> <div className={classes} {...elementProps} tabIndex={0} ref={ref}>
{children} {children}
</div> </div>
); );
} });
TextLikeInput.displayName = 'TextLikeInput';
export default memo(TextLikeInput);
@@ -11,6 +11,10 @@ interface TimeInputDurationProps {
onSubmit: (value: string) => void; onSubmit: (value: string) => void;
} }
interface ParentFocusableInput extends HTMLInputElement {
focusParentElement: () => void;
}
export default memo(TimeInputDuration); export default memo(TimeInputDuration);
function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) { function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
@@ -18,7 +22,8 @@ function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(initialValue); const [value, setValue] = useState(initialValue);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<ParentFocusableInput>(null);
const textRef = useRef<ParentFocusableInput>(null);
// when we go into edit mode, set focus to the input // when we go into edit mode, set focus to the input
useEffect(() => { useEffect(() => {
@@ -36,7 +41,10 @@ function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
}, [initialValue, isEditing]); }, [initialValue, isEditing]);
const handleFakeFocus = () => setIsEditing(true); const handleFakeFocus = () => setIsEditing(true);
const handleFakeBlur = () => setIsEditing(false); const handleFakeBlur = () => {
setIsEditing(false);
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
};
const handleUpdate = useCallback( const handleUpdate = useCallback(
(newValue: string) => { (newValue: string) => {
@@ -45,28 +53,33 @@ function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
// if the user sends an empty string, we want to clear the value // if the user sends an empty string, we want to clear the value
if (newValue === '') { if (newValue === '') {
onSubmit(newValue); onSubmit(newValue);
inputRef.current?.focusParentElement();
return; return;
} }
// we dont know the values in the rundown, escalate to handler // we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) { if (newValue.startsWith('p') || newValue.startsWith('+')) {
onSubmit(newValue); onSubmit(newValue);
inputRef.current?.focusParentElement();
return; return;
} }
const valueInMillis = parseUserTime(newValue); const valueInMillis = parseUserTime(newValue);
if (valueInMillis < 0 || isNaN(valueInMillis)) { if (valueInMillis < 0 || isNaN(valueInMillis)) {
setValue(initialValue); setValue(initialValue);
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
return; return;
} }
// if the value is the same, we may still want to push the lock change // if the value is the same, we may still want to push the lock change
if (valueInMillis === initialValue && lockedValue) { if (valueInMillis === initialValue && lockedValue) {
inputRef.current?.focusParentElement();
return; return;
} }
onSubmit(newValue); onSubmit(newValue);
setValue(Number(newValue)); setValue(Number(newValue));
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
}, },
[initialValue, lockedValue, onSubmit], [initialValue, lockedValue, onSubmit],
); );
@@ -82,7 +95,13 @@ function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
handleCancelUpdate={handleFakeBlur} handleCancelUpdate={handleFakeBlur}
/> />
) : ( ) : (
<TextLikeInput onClick={handleFakeFocus} onFocus={handleFakeFocus} muted={!lockedValue} delayed={delayed}> <TextLikeInput
onClick={handleFakeFocus}
onFocus={handleFakeFocus}
muted={!lockedValue}
delayed={delayed}
ref={textRef}
>
{children} {children}
</TextLikeInput> </TextLikeInput>
); );
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-electron", "name": "ontime-electron",
"version": "3.13.1", "version": "3.14.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "3.13.1", "version": "3.14.0",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "3.13.1", "version": "3.14.0",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"keywords": [ "keywords": [
"ontime", "ontime",
+17
View File
@@ -119,6 +119,9 @@ importers:
'@sentry/react': '@sentry/react':
specifier: ^8.43.0 specifier: ^8.43.0
version: 8.45.0(react@18.3.1) version: 8.45.0(react@18.3.1)
'@table-nav/react':
specifier: ^0.0.7
version: 0.0.7(@table-nav/core@0.0.7)
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.62.7 specifier: ^5.62.7
version: 5.62.7(react@18.3.1) version: 5.62.7(react@18.3.1)
@@ -1949,6 +1952,14 @@ packages:
resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
'@table-nav/core@0.0.7':
resolution: {integrity: sha512-pCh18jHDRe3tw9sJZXfKi4cSD6VjHbn40CYdqhp5X91SIX7rakDEQAsTx6F7Fv9TUv265l+5rUDcYNaJ0N0cqQ==}
'@table-nav/react@0.0.7':
resolution: {integrity: sha512-S+DsD/qDqp50Z4dqt5tZFMWA3sRu0OOT/grMQuq/z/52jPEKJB+b9t+YSH8Ms55vCJOJ0DxuYldJpYrJLMG5ew==}
peerDependencies:
'@table-nav/core': ^0.0.7
'@tanstack/eslint-plugin-query@5.8.4': '@tanstack/eslint-plugin-query@5.8.4':
resolution: {integrity: sha512-KVgcMc+Bn1qbwkxYVWQoiVSNEIN4IAiLj3cUH/SAHT8m8E59Y97o8ON1syp0Rcw094ItG8pEVZFyQuOaH6PDgQ==} resolution: {integrity: sha512-KVgcMc+Bn1qbwkxYVWQoiVSNEIN4IAiLj3cUH/SAHT8m8E59Y97o8ON1syp0Rcw094ItG8pEVZFyQuOaH6PDgQ==}
peerDependencies: peerDependencies:
@@ -6698,6 +6709,12 @@ snapshots:
dependencies: dependencies:
defer-to-connect: 2.0.1 defer-to-connect: 2.0.1
'@table-nav/core@0.0.7': {}
'@table-nav/react@0.0.7(@table-nav/core@0.0.7)':
dependencies:
'@table-nav/core': 0.0.7
'@tanstack/eslint-plugin-query@5.8.4(eslint@8.56.0)(typescript@5.5.3)': '@tanstack/eslint-plugin-query@5.8.4(eslint@8.56.0)(typescript@5.5.3)':
dependencies: dependencies:
'@typescript-eslint/utils': 5.62.0(eslint@8.56.0)(typescript@5.5.3) '@typescript-eslint/utils': 5.62.0(eslint@8.56.0)(typescript@5.5.3)