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); });