refactor: cuesheet design review

fix: parsing of custom fields for blocks
refactor: extract cuesheet settings
refactor: cuesheet actions
refactor: improve cuesheet performance on resizing
This commit is contained in:
Carlos Valente
2025-06-29 08:51:38 +02:00
committed by Carlos Valente
parent 0726c04f20
commit 8ad260d28a
61 changed files with 1421 additions and 734 deletions
@@ -0,0 +1,35 @@
@import '../CuesheetTable.module.scss';
.blockRow {
margin-top: 1rem;
width: 100%;
display: flex;
align-items: start;
font-size: 1rem;
border-radius: 2px 0 0 0;
background-color: $gray-1300;
border-left: 4px solid var(--user-bg, $gray-500);
background: color-mix(in srgb, transparent 90%, var(--user-bg, $gray-500) 10%);
position: relative;
line-height: 1em;
td {
min-height: 3.5rem;
padding-top: 0.75rem !important; // fighting styles from cuesheet-table
}
.indexColumn {
background-color: transparent;
}
.title {
font-weight: 600;
}
&:hover {
outline: 1px solid $blue-500;
outline-offset: -1px;
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
}
}
@@ -1,47 +1,70 @@
import { memo, useRef } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table';
import { EntryId, OntimeEntry } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton';
import { useCurrentBlockId } from '../../../../common/hooks/useSocket';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import style from '../CuesheetTable.module.scss';
import style from './BlockRow.module.scss';
interface BlockRowProps {
blockId: EntryId;
colour: string;
hidePast: boolean;
title: string;
columnCount: number;
rowId: string;
rowIndex: number;
table: Table<OntimeEntry>;
}
function BlockRow({ hidePast, title, columnCount }: BlockRowProps) {
export default function BlockRow({ blockId, colour, hidePast, rowId, rowIndex, table }: BlockRowProps) {
const { currentBlockId } = useCurrentBlockId();
const firstCellRef = useRef<null | HTMLTableCellElement>(null);
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
const showActionMenu = usePersistedCuesheetOptions((state) => state.showActionMenu);
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
if (hidePast && !currentBlockId) {
return null;
}
// guard the use case where user has hidden all columns
const fillColumns = Math.min(columnCount, 1);
const paddingRows = new Array(fillColumns).fill(null);
return (
<tr className={style.blockRow}>
<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 className={style.blockRow} style={{ '--user-bg': colour }}>
{showActionMenu && (
<td className={style.actionColumn} tabIndex={-1} role='cell'>
<IconButton
aria-label='Options'
variant='subtle-white'
size='small'
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const yPos = 8 + rect.y + rect.height / 2;
openMenu({ x: rect.x, y: yPos }, blockId, rowIndex);
}}
/>
);
})}
>
<IoEllipsisHorizontal />
</IconButton>
</td>
)}
{!hideIndexColumn && <td className={style.indexColumn} tabIndex={-1} role='cell' />}
{table
.getRow(rowId)
.getVisibleCells()
.map((cell) => {
return (
<td
key={cell.id}
tabIndex={-1}
style={{
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
}}
role='cell'
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
}
export default memo(BlockRow);
@@ -1,16 +1,19 @@
import { MutableRefObject } from 'react';
import { MutableRefObject, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { RowModel, Table } from '@tanstack/react-table';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeEntry } from 'ontime-types';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeBlock, OntimeEntry, Rundown } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { RUNDOWN } from '../../../../common/api/constants';
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 { usePersistedCuesheetOptions } from '../../cuesheet.options';
import BlockRow from './BlockRow';
import DelayRow from './DelayRow';
import EventRow from './EventRow';
import { useVisibleRowsStore } from './visibleRowsStore';
interface CuesheetBodyProps {
rowModel: RowModel<OntimeEntry>;
@@ -19,8 +22,29 @@ interface CuesheetBodyProps {
}
export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetBodyProps) {
const queryClient = useQueryClient();
const { selectedEventId } = useSelectedEventId();
const { hideDelays, hidePast } = useCuesheetOptions();
const hidePast = usePersistedCuesheetOptions((state) => state.hidePast);
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
const { addVisibleRow, removeVisibleRow } = useVisibleRowsStore();
const observer = useMemo(
() =>
new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
addVisibleRow(entry.target.id);
} else {
removeVisibleRow(entry.target.id);
}
}
},
{ rootMargin: '400px' },
),
[addVisibleRow, removeVisibleRow],
);
const getVisibleColumns = lazyEvaluate(() => table.getVisibleFlatColumns());
const getColumnHash = lazyEvaluate(() => {
@@ -36,6 +60,7 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
let eventIndex = 0;
// for the first event, it will be past if there is something selected
let isPast = Boolean(selectedEventId);
let hadBlock = false;
return (
<tbody>
{rowModel.rows.map((row, index) => {
@@ -47,8 +72,17 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
}
if (isOntimeBlock(entry)) {
const columnCount = getVisibleColumns().length;
return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />;
return (
<BlockRow
key={key}
blockId={entry.id}
colour={entry.colour}
hidePast={isPast && hidePast}
rowId={row.id}
rowIndex={row.index}
table={table}
/>
);
}
if (isOntimeDelay(entry)) {
if (isPast && hidePast) {
@@ -59,20 +93,27 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
return null;
}
return <DelayRow key={key} duration={delayVal} />;
let parentBgColour: string | null = null;
if (entry.parent) {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
const parentEntry = rundown?.entries[entry.parent];
parentBgColour = (parentEntry as OntimeBlock).colour ?? null;
}
return <DelayRow key={key} duration={delayVal} parentBgColour={parentBgColour} />;
}
if (isOntimeEvent(entry)) {
eventIndex++;
const isSelected = key === selectedEventId;
const columnHash = getColumnHash();
if (isPast && hidePast) {
return null;
}
let rowBgColour: string | undefined;
if (isSelected) {
rowBgColour = '#D20300'; // $red-700
rowBgColour = '#087A27'; // $active-green
} else if (entry.colour) {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(entry.colour).backgroundColor);
@@ -84,6 +125,19 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
}
}
let parentBgColour: string | undefined;
let firstAfterBlock = false;
if (entry.parent) {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
const parentEntry = rundown?.entries[entry.parent];
parentBgColour = (parentEntry as OntimeBlock).colour;
hadBlock = true;
} else if (hadBlock) {
firstAfterBlock = true;
hadBlock = false;
}
return (
<EventRow
key={row.id}
@@ -94,8 +148,11 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
rowBgColour={rowBgColour}
parentBgColour={parentBgColour}
table={table}
firstAfterBlock={firstAfterBlock}
columnHash={columnHash}
observer={observer}
/>
);
}
@@ -3,7 +3,7 @@ import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { useCuesheetOptions } from '../../cuesheet.options';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import { SortableCell } from './SortableCell';
@@ -14,7 +14,8 @@ interface CuesheetHeaderProps {
}
export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
const showActionMenu = usePersistedCuesheetOptions((state) => state.showActionMenu);
return (
<thead className={style.tableHeader}>
@@ -31,7 +32,6 @@ export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
)}
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
// @ts-expect-error -- we inject this into react-table
const customBackground = header.column.columnDef?.meta?.colour;
@@ -42,7 +42,11 @@ export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
}
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
<SortableCell
key={header.column.columnDef.id}
header={header}
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
@@ -0,0 +1,18 @@
@import '../CuesheetTable.module.scss';
.delayRow {
width: calc(100vw - 2rem);
color: $ontime-delay-text;
border-left: 4px solid var(--user-bg);
td {
width: 100%;
padding-block: 0.5rem;
text-align: center;
transform: translateX(45%);
&:first-letter {
text-transform: uppercase;
}
}
}
@@ -2,17 +2,23 @@ import { memo } from 'react';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import style from '../CuesheetTable.module.scss';
import style from './DelayRow.module.scss';
interface DelayRowProps {
duration: number;
parentBgColour: string | null;
}
function DelayRow({ duration }: DelayRowProps) {
function DelayRow({ duration, parentBgColour }: DelayRowProps) {
const delayTime = millisToDelayString(duration, 'expanded');
return (
<tr className={style.delayRow}>
<tr
className={style.delayRow}
style={{
'--user-bg': parentBgColour ?? 'transparent',
}}
>
<td tabIndex={0} role='cell'>
{delayTime}
</td>
@@ -1,3 +1,12 @@
.imageInput {
&::placeholder {
opacity: 0.2;
}
&:hover::placeholder {
opacity: 1;
}
}
.imageCell {
position: relative;
min-height: 2rem;
@@ -6,6 +15,8 @@
.overlay {
display: grid;
place-content: center;
justify-items: center;
gap: 1rem;
background-color: $black-60;
}
}
@@ -1,5 +1,6 @@
import { memo } from 'react';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import style from './EditableImage.module.scss';
@@ -22,10 +23,17 @@ function EditableImage({ initialValue, updateValue }: EditableImageProps) {
updateValue(newValue);
};
const openInNewTab = () => {
if (initialValue) {
window.open(initialValue, '_blank', 'noopener,noreferrer');
}
};
if (!initialValue) {
return (
<Input
variant='ghosted'
className={style.imageInput}
fluid
placeholder='Paste image URL'
onBlur={(event) => handleUpdate(event.currentTarget.value)}
@@ -42,7 +50,12 @@ function EditableImage({ initialValue, updateValue }: EditableImageProps) {
return (
<div className={style.imageCell}>
<div className={style.overlay}>
<button onClick={() => handleUpdate('')}>Delete</button>
<Button variant='subtle-white' onClick={openInNewTab}>
Preview
</Button>
<Button variant='subtle-destructive' onClick={() => handleUpdate('')}>
Delete
</Button>
</div>
<img loading='lazy' src={initialValue} className={style.image} />
</div>
@@ -0,0 +1,40 @@
@import "../CuesheetTable.module.scss";
.eventRow {
vertical-align: top;
background: color-mix(in srgb, transparent 92%, var(--user-bg, $gray-500) 8%);
border-left: 4px solid var(--user-bg, $gray-500);
&:hover {
outline: 1px solid $blue-500;
outline-offset: -1px;
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
}
&.firstAfterBlock {
margin-top: 1rem;
}
&.skip {
position: relative;
&::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
-45deg,
rgba(255, 255, 255, 0.03),
rgba(255, 255, 255, 0.03) 10px,
rgba(255, 255, 255, 0.08) 10px,
rgba(255, 255, 255, 0.08) 20px
);
}
}
td {
background-color: $gray-1200;
border-radius: 2px;
}
}
@@ -1,4 +1,4 @@
import { memo, MutableRefObject, useLayoutEffect, useRef, useState } from 'react';
import { memo, MutableRefObject, useLayoutEffect, useRef } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table';
import { OntimeEntry, OntimeEvent, RGBColour } from 'ontime-types';
@@ -6,10 +6,12 @@ import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import IconButton from '../../../../common/components/buttons/IconButton';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { useCuesheetOptions } from '../../cuesheet.options';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import style from '../CuesheetTable.module.scss';
import { useVisibleRowsStore } from './visibleRowsStore';
import style from './EventRow.module.scss';
interface EventRowProps {
rowId: string;
@@ -21,9 +23,12 @@ interface EventRowProps {
skip?: boolean;
colour?: string;
rowBgColour?: string;
parentBgColour?: string;
table: Table<OntimeEntry>;
/** hack to force re-rendering of the row when the column sizes change */
columnHash: string;
observer: IntersectionObserver;
firstAfterBlock: boolean;
}
export default memo(EventRow, (prevProps, nextProps) => {
@@ -35,34 +40,36 @@ export default memo(EventRow, (prevProps, nextProps) => {
prevProps.isPast === nextProps.isPast &&
prevProps.selectedRef === nextProps.selectedRef &&
prevProps.rowBgColour === nextProps.rowBgColour &&
prevProps.parentBgColour === nextProps.parentBgColour &&
prevProps.columnHash === nextProps.columnHash
);
});
function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, rowBgColour, table }: EventRowProps) {
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
function EventRow({
rowId,
event,
eventIndex,
rowIndex,
isPast,
selectedRef,
rowBgColour,
parentBgColour,
table,
observer,
firstAfterBlock,
}: EventRowProps) {
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
const showActionMenu = usePersistedCuesheetOptions((state) => state.showActionMenu);
const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false);
const isVisible = useVisibleRowsStore((state) => state.visibleRows.has(rowId));
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
// store a reference of the row in the observer
useLayoutEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{
root: null,
threshold: 0.01,
},
);
const handleRefCurrent = ownRef.current;
if (selectedRef) {
setIsVisible(true);
} else if (handleRefCurrent) {
if (handleRefCurrent) {
observer.observe(handleRefCurrent);
}
@@ -71,22 +78,28 @@ function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, row
observer.unobserve(handleRefCurrent);
}
};
}, [ownRef, selectedRef]);
}, [observer]);
const { color, backgroundColor } = getAccessibleColour(event.colour);
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.6 });
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
return (
<tr
className={cx([style.eventRow, event.skip ?? style.skip])}
style={{ opacity: `${isPast ? '0.2' : '1'}` }}
id={rowId}
className={cx([style.eventRow, event.skip && style.skip, firstAfterBlock && style.firstAfterBlock, Boolean(parentBgColour) && style.hasParent])}
style={{
opacity: `${isPast ? '0.2' : '1'}`,
'--user-bg': parentBgColour ?? 'transparent',
}}
ref={selectedRef ?? ownRef}
>
{showActionMenu && (
<td className={style.actionColumn} tabIndex={-1} role='cell'>
<IconButton
aria-label='Options'
variant='subtle-white'
size='small'
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const yPos = 8 + rect.y + rect.height / 2;
@@ -105,12 +118,15 @@ function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, row
{isVisible
? table
.getRow(rowId)
?.getVisibleCells()
.getVisibleCells()
.map((cell) => {
return (
<td
key={cell.id}
style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}
style={{
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
backgroundColor: rowBgColour,
}}
tabIndex={-1}
role='cell'
>
@@ -0,0 +1,9 @@
.muted {
opacity: 0.4; // same as the time input with muted text
line-height: 2rem; // input height
}
.numeric {
font-variant-numeric: tabular-nums;
letter-spacing: 0.5px;
}
@@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react';
import { cx } from '../../../../common/utils/styleUtils';
import style from './MutedText.module.scss';
interface MutedTextProps {
numeric?: boolean;
}
export default function MutedText({ numeric, children }: PropsWithChildren<MutedTextProps>) {
return <span className={cx([style.muted, numeric && style.numeric])}>{children}</span>;
}
@@ -4,15 +4,15 @@ import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import styles from '../CuesheetTable.module.scss';
import style from '../CuesheetTable.module.scss';
interface SortableCellProps {
header: Header<OntimeEntry, unknown>;
style: CSSProperties;
injectedStyles: CSSProperties;
children: ReactNode;
}
export function SortableCell({ header, style, children }: SortableCellProps) {
export function SortableCell({ header, injectedStyles, children }: SortableCellProps) {
const { column, colSpan } = header;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -21,7 +21,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
// build drag styles
const dragStyle = {
...style,
...injectedStyles,
opacity: isDragging ? 0.5 : 1,
transform: CSS.Translate.toString(transform),
transition,
@@ -34,10 +34,11 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
</div>
<div
{...{
onDoubleClick: () => header.column.resetSize(),
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
}}
className={styles.resizer}
className={style.resizer}
/>
</th>
);
@@ -7,10 +7,12 @@
display: flex;
align-items: center;
gap: 0.25rem;
letter-spacing: 1px;
letter-spacing: 0.5px;
font-size: 1rem;
font-variant-numeric: tabular-nums;
overflow: hidden;
&.delayed {
color: $ontime-delay-text;
}
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, isOntimeEvent, OntimeEntry, OntimeEvent, TimeStrategy } from 'ontime-types';
import { CustomFields, isOntimeDelay, isOntimeEvent, OntimeEntry, TimeStrategy } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
@@ -9,6 +9,7 @@ import { formatDuration, formatTime } from '../../../../common/utils/time';
import DurationInput from './DurationInput';
import EditableImage from './EditableImage';
import MultiLineCell from './MultiLineCell';
import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput';
@@ -17,24 +18,29 @@ function MakeStart({ getValue, row, table }: CellContext<OntimeEntry, unknown>)
return null;
}
const { handleUpdateTimer } = table.options.meta;
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
const event = row.original;
if (!isOntimeEvent(event)) {
return <MutedText numeric>{formatTime(getValue() as number)}</MutedText>;
}
const { handleUpdateTimer } = table.options.meta;
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeStart', newValue);
const startTime = getValue() as number;
const isStartLocked = !(row.original as OntimeEvent).linkStart;
const delayValue = (row.original as OntimeEvent)?.delay ?? 0;
const isStartLocked = !event.linkStart;
const displayTime = showDelayedTimes ? startTime + delayValue : startTime;
const displayTime = showDelayedTimes ? startTime + event.delay : startTime;
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
const formattedTime = formatTime(displayTime, formatOpts);
return (
<TimeInput initialValue={startTime} onSubmit={update} lockedValue={isStartLocked} delayed={delayValue !== 0}>
<TimeInput initialValue={startTime} onSubmit={update} lockedValue={isStartLocked} delayed={event.delay !== 0}>
{formattedTime}
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(startTime)} />
<DelayIndicator delayValue={event.delay} tooltipPrefix={millisToString(startTime)} />
</TimeInput>
);
}
@@ -44,24 +50,29 @@ function MakeEnd({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
return null;
}
const { handleUpdateTimer } = table.options.meta;
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
const event = row.original;
if (!isOntimeEvent(event)) {
return <MutedText numeric>{formatTime(getValue() as number, formatOpts)}</MutedText>;
}
const { handleUpdateTimer } = table.options.meta;
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeEnd', newValue);
const endTime = getValue() as number;
const isEndLocked = (row.original as OntimeEvent).timeStrategy === TimeStrategy.LockEnd;
const delayValue = (row.original as OntimeEvent)?.delay ?? 0;
const isEndLocked = event.timeStrategy === TimeStrategy.LockEnd;
const displayTime = showDelayedTimes ? endTime + delayValue : endTime;
const displayTime = showDelayedTimes ? endTime + event.delay : endTime;
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
const formattedTime = formatTime(displayTime, formatOpts);
return (
<TimeInput initialValue={endTime} onSubmit={update} lockedValue={isEndLocked} delayed={delayValue !== 0}>
<TimeInput initialValue={endTime} onSubmit={update} lockedValue={isEndLocked} delayed={event.delay !== 0}>
{formattedTime}
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(endTime)} />
<DelayIndicator delayValue={event.delay} tooltipPrefix={millisToString(endTime)} />
</TimeInput>
);
}
@@ -71,13 +82,19 @@ function MakeDuration({ getValue, row, table }: CellContext<OntimeEntry, unknown
return null;
}
const { hideTableSeconds } = table.options.meta.options;
const event = row.original;
if (!isOntimeEvent(event)) {
return <MutedText numeric>{formatDuration(getValue() as number, hideTableSeconds)}</MutedText>;
}
const { handleUpdateTimer } = table.options.meta;
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'duration', newValue);
const duration = getValue() as number;
const isDurationLocked = (row.original as OntimeEvent).timeStrategy === TimeStrategy.LockDuration;
const formattedDuration = formatDuration(duration, false);
const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration;
const formattedDuration = formatDuration(duration, hideTableSeconds);
return (
<DurationInput initialValue={duration} onSubmit={update} lockedValue={isDurationLocked}>
@@ -91,17 +108,15 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unk
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
[column.id, row.index, table.options.meta],
);
const event = row.original;
if (!isOntimeEvent(event)) {
// not all entries have all properties (eg blocks)
const initialValue = row.original[column.id as keyof OntimeEntry];
if (initialValue === undefined) {
return null;
}
const initialValue = event[column.id as keyof OntimeEntry] ?? '';
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
}
@@ -110,12 +125,11 @@ function LazyImage({ row, column, table }: CellContext<OntimeEntry, unknown>) {
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
[column.id, row.index, table.options.meta],
);
const event = row.original;
if (!isOntimeEvent(event)) {
if (isOntimeDelay(event)) {
return null;
}
@@ -128,17 +142,15 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, un
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
[column.id, row.index, table.options.meta],
);
const event = row.original;
if (!isOntimeEvent(event)) {
// not all entries have all properties (eg blocks)
const initialValue = row.original[column.id as keyof OntimeEntry];
if (initialValue === undefined) {
return null;
}
const initialValue = event[column.id as keyof OntimeEntry] ?? '';
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
}
@@ -147,20 +159,25 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeEntry, unknow
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
[column.id, row.index, table.options.meta],
);
const event = row.original;
if (!isOntimeEvent(event)) {
if (isOntimeDelay(event)) {
return null;
}
// fields will not contain the field if there is no value set by the user
// event if there is no initial value, we still render the cell
const initialValue = event.custom[column.id] ?? '';
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
}
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeEntry>[] {
/**
* we cant use the createColumnHelper() because we have custom logic for rendering the cells
* This means that the display columns: index and action are added inline by the row components
*/
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
accessorKey: key,
id: key,
@@ -0,0 +1,18 @@
import { create } from 'zustand';
interface VisibleRowsStore {
visibleRows: Set<string>;
addVisibleRow: (id: string) => void;
removeVisibleRow: (id: string) => void;
}
export const useVisibleRowsStore = create<VisibleRowsStore>((set) => ({
visibleRows: new Set(),
addVisibleRow: (id) => set((state) => ({ visibleRows: new Set(state.visibleRows).add(id) })),
removeVisibleRow: (id) =>
set((state) => {
const newSet = new Set(state.visibleRows);
newSet.delete(id);
return { visibleRows: newSet };
}),
}));