diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx
index 63b17c547..c5af0b587 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx
@@ -1,7 +1,14 @@
-import { useCallback, useEffect, useMemo, useRef } from 'react';
-import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
+import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
+import {
+ ContextProp,
+ ItemProps,
+ TableComponents,
+ TableProps,
+ TableVirtuoso,
+ TableVirtuosoHandle,
+} from 'react-virtuoso';
import { useTableNav } from '@table-nav/react';
-import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
+import { ColumnDef, getCoreRowModel, Table, useReactTable } from '@tanstack/react-table';
import { isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField } from 'ontime-types';
import EmptyPage from '../../../common/components/state/EmptyPage';
@@ -170,6 +177,28 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
const allLeafColumns = table.getAllLeafColumns();
const { rows } = table.getRowModel();
+ const virtuosoContext = useMemo(
+ () => ({
+ columnSizeVars,
+ cursor,
+ listeners,
+ rows,
+ table,
+ }),
+ [columnSizeVars, cursor, listeners, rows, table],
+ );
+
+ 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, table]);
const isLoading = !data || status === 'pending';
@@ -191,113 +220,146 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
,
- Table: ({ style: injectedStyles, ...virtuosoProps }) => {
- return (
-
- );
- },
- TableRow: ({ item: _item, style: injectedStyles, ...virtuosoProps }) => {
- // eslint-disable-next-line react/destructuring-assignment
- const rowIndex = virtuosoProps['data-index'];
- const row = rows[rowIndex];
- const key = row.original.id;
- const entry = row.original;
- const hasCursor = entry.id === cursor;
-
- if (isOntimeGroup(entry)) {
- return (
-
- );
- }
-
- if (isOntimeDelay(entry)) {
- return (
-
- );
- }
-
- if (isOntimeMilestone(entry)) {
- return (
-
- );
- }
-
- return (
-
- );
- },
- TableHead: (virtuosoProps) => ,
- }}
- fixedHeaderContent={() => {
- 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 ;
- });
- }}
+ components={virtuosoComponents}
+ fixedHeaderContent={fixedHeaderContent}
/>
>
);
}
+
+
+interface CuesheetVirtuosoContext {
+ columnSizeVars: { [key: string]: number };
+ cursor: string | null;
+ listeners: ReturnType['listeners'];
+ rows: ReturnType['getRowModel']>['rows'];
+ table: Table;
+}
+
+const EmptyPlaceholder = memo(function EmptyPlaceholder() {
+ 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,
+};
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx
index 794be0fb8..a6e9a5585 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx
@@ -91,6 +91,7 @@ export default function EventRow({
}}
data-cursor={hasCursor}
data-testid='cuesheet-event'
+ data-entry-id={id}
{...virtuosoProps}
>
{cuesheetMode === AppMode.Edit && (
@@ -127,6 +128,8 @@ export default function EventRow({
}}
tabIndex={-1}
role='cell'
+ data-testid={`cuesheet-cell-${cell.column.id}`}
+ data-column-id={cell.column.id}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx
index 8727a8805..4ce03306c 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx
@@ -5,12 +5,14 @@ import useReactiveTextInput from '../../../../common/components/input/text-input
interface MultiLineCellProps {
initialValue: string;
+ fieldId?: string;
+ fieldLabel?: string;
handleUpdate: (newValue: string) => void;
}
export default memo(MultiLineCell);
-function MultiLineCell({ initialValue, handleUpdate }: MultiLineCellProps) {
+function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: MultiLineCellProps) {
const ref = useRef(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
@@ -30,6 +32,8 @@ function MultiLineCell({ initialValue, handleUpdate }: MultiLineCellProps) {
onBlur={onBlur}
onKeyDown={onKeyDown}
spellCheck={false}
+ data-testid={fieldId ? `cuesheet-editor-${fieldId}` : undefined}
+ aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
/>
);
}
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx
index 9445b80cb..b485f7a80 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx
@@ -5,13 +5,18 @@ import useReactiveTextInput from '../../../../common/components/input/text-input
interface SingleLineCellProps {
initialValue: string;
+ fieldId?: string;
+ fieldLabel?: string;
allowSubmitSameValue?: boolean;
handleUpdate: (newValue: string) => void;
handleCancelUpdate?: () => void;
}
const SingleLineCell = forwardRef(
- ({ initialValue, allowSubmitSameValue, handleUpdate, handleCancelUpdate }: SingleLineCellProps, inputRef) => {
+ (
+ { initialValue, fieldId, fieldLabel, allowSubmitSameValue, handleUpdate, handleCancelUpdate }: SingleLineCellProps,
+ inputRef,
+ ) => {
const ref = useRef(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
@@ -47,6 +52,8 @@ const SingleLineCell = forwardRef(
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
+ data-testid={fieldId ? `cuesheet-editor-${fieldId}` : undefined}
+ aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
/>
);
},
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx
index 75598c561..e486a60dd 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx
@@ -17,6 +17,10 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput';
+function getColumnLabel(column: CellContext['column']): string {
+ return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
+}
+
function MakeStart({ getValue, row, table, column }: CellContext) {
if (!table.options.meta) {
return null;
@@ -146,7 +150,14 @@ function MakeMultiLineField({ row, column, table }: CellContext{initialValue};
}
- return ;
+ return (
+
+ );
}
function LazyImage({ row, column, table }: CellContext) {
@@ -186,7 +197,14 @@ function MakeSingleLineField({ row, column, table }: CellContext{initialValue};
}
- return ;
+ return (
+
+ );
}
function MakeFlagField({ row }: CellContext) {
@@ -219,7 +237,14 @@ function MakeCustomField({ row, column, table }: CellContext{initialValue};
}
- return ;
+ return (
+
+ );
}
/**
diff --git a/e2e/tests/features/202-cuesheet.spec.ts b/e2e/tests/features/202-cuesheet.spec.ts
index b59cf66ae..4d50b1127 100644
--- a/e2e/tests/features/202-cuesheet.spec.ts
+++ b/e2e/tests/features/202-cuesheet.spec.ts
@@ -1,10 +1,57 @@
import { expect, test } from '@playwright/test';
test('cuesheet displays events', async ({ page }) => {
- // same elements in cuesheet
- await page.goto('http://localhost:4001/cuesheet');
- await expect(page.getByRole('row', { name: 'Lunch break' })).toBeVisible();
- await expect(page.getByRole('row', { name: 'Afternoon break' })).toBeVisible();
-
- await expect(page.locator('#cuesheet')).toBeVisible();
+ await page.goto('/cuesheet');
+ await expect(page.getByTestId('cuesheet')).toBeVisible();
+ await expect(page.getByTestId('cuesheet-event').first()).toBeVisible();
+});
+
+test('cuesheet datagrid keeps keyboard focus flow while editing text cells', async ({ page }) => {
+ await page.goto('/cuesheet');
+
+ const firstEvent = page.getByTestId('cuesheet-event').first();
+ await expect(firstEvent).toBeVisible();
+
+ const cueEditor = firstEvent.getByTestId('cuesheet-editor-cue');
+ const titleEditor = firstEvent.getByTestId('cuesheet-editor-title');
+ const noteEditor = firstEvent.getByTestId('cuesheet-editor-note');
+
+ /**
+ * 1. focus a cell in the datagrid single line text
+ * submitting the data returns the focus to the parent
+ */
+ await titleEditor.click();
+ await expect(titleEditor).toBeFocused();
+ const updatedTitle = `focus-title-${Date.now()}`;
+ await titleEditor.fill(updatedTitle);
+ await titleEditor.press('Enter');
+ await expect(titleEditor).not.toBeFocused();
+ await expect(titleEditor).toHaveValue(updatedTitle);
+
+ /**
+ * 2. navigate and modify multiline text cell
+ * submitting works with ctrl/cmd + enter and the focus returns to the parent
+ */
+ await page.keyboard.press('ArrowRight');
+ await page.keyboard.press('Enter');
+ await expect(noteEditor).toBeFocused();
+ const updatedNote = `focus-note-${Date.now()}`;
+ await noteEditor.fill(updatedNote);
+ await noteEditor.press('ControlOrMeta+Enter');
+ await expect(noteEditor).not.toBeFocused();
+ await expect(noteEditor).toHaveValue(updatedNote);
+
+ /**
+ * 2. navigate and modify single line text cell again
+ * pressing escape cancels the edit and the focus returns to the parent
+ */
+ await page.keyboard.press('ArrowLeft');
+ await page.keyboard.press('Enter');
+ await expect(titleEditor).toBeFocused();
+ const cueBeforeCancel = await cueEditor.inputValue();
+ await cueEditor.click();
+ await cueEditor.fill(`${cueBeforeCancel} temporary`);
+ await cueEditor.press('Escape');
+ await expect(cueEditor).not.toBeFocused();
+ await expect(cueEditor).toHaveValue(cueBeforeCancel);
});