Compare commits

..

1 Commits

Author SHA1 Message Date
google-labs-jules[bot] fbbbaa5922 fix: stabilize cuesheet table components to prevent focus and data loss
This commit addresses the issue where focusing cells or inputs in the cuesheet table was lost during re-renders.

Changes:
- Extracted Virtuoso components (Table, TableRow, TableHead, EmptyPlaceholder) into stable, memoized components outside the main CuesheetTable component.
- Used Virtuoso's context API to pass dynamic state to the extracted components.
- Stabilized the 'meta' object of TanStack Table by using a ref for the data, reducing unnecessary re-renders of cell components.
- Modified useReactiveTextInput to skip synchronizing the 'text' state with 'initialText' when the input is focused, preventing user input from being overwritten by external changes.

These changes ensure that focus is maintained even when the table re-renders due to concurrent edits or submitting changes, and typed content is preserved.

Co-authored-by: cpvalente <34649812+cpvalente@users.noreply.github.com>
2026-02-08 11:24:48 +00:00
18 changed files with 260 additions and 302 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.4.0",
"version": "4.3.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.4.0",
"version": "4.3.1",
"private": true,
"type": "module",
"dependencies": {
@@ -25,12 +25,15 @@ export default function useReactiveTextInput(
const isKeyboardSubmitting = useRef(false);
useEffect(() => {
const isFocused = document.activeElement === ref.current;
if (isFocused) return;
if (typeof initialText === 'undefined') {
setText('');
} else {
setText(initialText);
}
}, [initialText]);
}, [initialText, ref]);
/**
* @description Handles Input value change
@@ -11,7 +11,7 @@ import IconButton from '../buttons/IconButton';
import Info from '../info/Info';
import { ViewOption } from './viewParams.types';
import { getPreservedSearchParams, getURLSearchParamsFromObj } from './viewParams.utils';
import { getURLSearchParamsFromObj } from './viewParams.utils';
import { useViewParamsEditorStore } from './viewParamsEditor.store';
import { ViewParamsPresets } from './ViewParamsPresets';
import ViewParamsSection from './ViewParamsSection';
@@ -25,19 +25,17 @@ interface EditFormDrawerProps {
export default memo(ViewParamsEditor);
function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const [_, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
const handleClose = () => {
close();
};
const resetParams = () => {
setSearchParams(getPreservedParams());
setSearchParams();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
@@ -45,10 +43,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
const preservedParams = getPreservedParams();
preservedParams.forEach((value, key) => {
newSearchParams.append(key, value);
});
setSearchParams(newSearchParams);
if (isSmallScreen) {
@@ -191,26 +191,3 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
return newSearchParams;
}
/**
* Extracts search params that are not managed by the view params editor
* @param currentParams - The current URL search params
* @param paramFields - The view options that define the managed parameters
* @returns A new URLSearchParams object with preserved params
*/
export function getPreservedSearchParams(currentParams: URLSearchParams, paramFields: ViewOption[]) {
const managedParamIds = new Set<string>();
paramFields.forEach((section) => {
section.options.forEach((option) => {
managedParamIds.add(option.id);
});
});
const preserved = new URLSearchParams();
currentParams.forEach((value, key) => {
if (!managedParamIds.has(key)) {
preserved.append(key, value);
}
});
return preserved;
}
@@ -1,15 +1,8 @@
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import {
ContextProp,
ItemProps,
TableComponents,
TableProps,
TableVirtuoso,
TableVirtuosoHandle,
} from 'react-virtuoso';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, Table, useReactTable } from '@tanstack/react-table';
import { isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField } from 'ontime-types';
import { ColumnDef, getCoreRowModel, Row, Table, useReactTable } from '@tanstack/react-table';
import { EntryId, isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField } from 'ontime-types';
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
@@ -34,6 +27,115 @@ import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumn
import style from './CuesheetTable.module.scss';
interface VirtuosoContext {
table: Table<ExtendedEntry>;
rows: Row<ExtendedEntry>[];
cursor: EntryId | null;
columnSizeVars: Record<string, number | string>;
listeners: object;
}
const VirtuosoTable = memo(({ style: injectedStyles, context, ...virtuosoProps }: any) => {
const { columnSizeVars, listeners } = context as VirtuosoContext;
return (
<table
className={style.cuesheet}
id="cuesheet"
style={{ ...injectedStyles, ...columnSizeVars }}
{...listeners}
{...virtuosoProps}
/>
);
});
VirtuosoTable.displayName = 'VirtuosoTable';
const VirtuosoTableRow = memo(({ item: _item, context, ...virtuosoProps }: any) => {
const { table, rows, cursor } = context as VirtuosoContext;
const rowIndex = virtuosoProps['data-index'];
const row = rows[rowIndex];
if (!row) return null;
const key = row.original.id;
const entry = row.original;
const hasCursor = entry.id === cursor;
if (isOntimeGroup(entry)) {
return (
<GroupRow
key={key}
groupId={entry.id}
colour={entry.colour}
rowId={row.id}
rowIndex={row.index}
table={table}
injectedStyles={virtuosoProps.style}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
if (isOntimeDelay(entry)) {
return (
<DelayRow
key={key}
duration={entry.duration}
injectedStyles={virtuosoProps.style}
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={table}
injectedStyles={virtuosoProps.style}
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={table}
injectedStyles={virtuosoProps.style}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
});
VirtuosoTableRow.displayName = 'VirtuosoTableRow';
const VirtuosoTableHead = memo((props: any) => <thead className={style.tableHeader} {...props} />);
VirtuosoTableHead.displayName = 'VirtuosoTableHead';
const VirtuosoEmptyPlaceholder = memo(() => <EmptyTableBody text="No data in rundown" />);
VirtuosoEmptyPlaceholder.displayName = 'VirtuosoEmptyPlaceholder';
interface CuesheetTableProps {
columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode;
@@ -56,11 +158,14 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const { listeners } = useTableNav();
const dataRef = useRef(data);
dataRef.current = data;
const meta = useMemo(
() => ({
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
// check if value is the same
const event = data[rowIndex];
const event = dataRef.current[rowIndex];
if (!event) {
return;
@@ -91,7 +196,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
hideIndexColumn,
},
}),
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
[cuesheetMode, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
);
const { columnOrder, resetColumnOrder } = useColumnOrder(columns, tableRoot);
@@ -177,29 +282,18 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
const allLeafColumns = table.getAllLeafColumns();
const { rows } = table.getRowModel();
const virtuosoContext = useMemo(
() => ({
columnSizeVars,
cursor,
listeners,
rows,
table,
rows,
cursor,
columnSizeVars,
listeners,
}),
[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 <HeaderComponent key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />;
});
}, [cuesheetMode, table]);
const isLoading = !data || status === 'pending';
if (isLoading) {
@@ -222,144 +316,26 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
data={data}
context={virtuosoContext}
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
computeItemKey={computeItemKey}
increaseViewportBy={{ top: 100, bottom: 200 }}
components={virtuosoComponents}
fixedHeaderContent={fixedHeaderContent}
components={{
EmptyPlaceholder: VirtuosoEmptyPlaceholder,
Table: VirtuosoTable,
TableRow: VirtuosoTableRow,
TableHead: VirtuosoTableHead,
}}
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 <HeaderComponent key={headerGroup.id} cuesheetMode={cuesheetMode} headerGroup={headerGroup} />;
});
}}
/>
<TableMenu />
</>
);
}
interface CuesheetVirtuosoContext {
columnSizeVars: { [key: string]: number };
cursor: string | null;
listeners: ReturnType<typeof useTableNav>['listeners'];
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
table: Table<ExtendedEntry>;
}
const EmptyPlaceholder = memo(function EmptyPlaceholder() {
return <EmptyTableBody text='No data in rundown' />;
});
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,
};
@@ -91,7 +91,6 @@ export default function EventRow({
}}
data-cursor={hasCursor}
data-testid='cuesheet-event'
data-entry-id={id}
{...virtuosoProps}
>
{cuesheetMode === AppMode.Edit && (
@@ -128,8 +127,6 @@ 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())}
</td>
@@ -5,14 +5,12 @@ 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, fieldId, fieldLabel, handleUpdate }: MultiLineCellProps) {
function MultiLineCell({ initialValue, handleUpdate }: MultiLineCellProps) {
const ref = useRef<HTMLTextAreaElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
@@ -32,8 +30,6 @@ function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: Mult
onBlur={onBlur}
onKeyDown={onKeyDown}
spellCheck={false}
data-testid={fieldId ? `cuesheet-editor-${fieldId}` : undefined}
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
/>
);
}
@@ -5,18 +5,13 @@ 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, fieldId, fieldLabel, allowSubmitSameValue, handleUpdate, handleCancelUpdate }: SingleLineCellProps,
inputRef,
) => {
({ initialValue, allowSubmitSameValue, handleUpdate, handleCancelUpdate }: SingleLineCellProps, inputRef) => {
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
@@ -52,8 +47,6 @@ const SingleLineCell = forwardRef(
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
data-testid={fieldId ? `cuesheet-editor-${fieldId}` : undefined}
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
/>
);
},
@@ -17,10 +17,6 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput';
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
}
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
if (!table.options.meta) {
return null;
@@ -150,14 +146,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
return <GhostedText multiline>{initialValue}</GhostedText>;
}
return (
<MultiLineCell
initialValue={initialValue as string}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}
handleUpdate={update}
/>
);
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
}
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
@@ -197,14 +186,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
return <GhostedText>{initialValue}</GhostedText>;
}
return (
<SingleLineCell
initialValue={initialValue as string}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}
handleUpdate={update}
/>
);
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
}
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
@@ -237,14 +219,7 @@ function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unkn
return <GhostedText multiline>{initialValue}</GhostedText>;
}
return (
<MultiLineCell
initialValue={initialValue}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}
handleUpdate={update}
/>
);
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
}
/**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.4.0",
"version": "4.3.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.4.0",
"version": "4.3.1",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.4.0",
"version": "4.3.1",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -499,7 +499,7 @@ export async function editCustomField(
if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
updateBackgroundRundown(rundownId, backgroundRundown);
updateBackgroundRundown(rundown.id, backgroundRundown);
}
}
@@ -539,7 +539,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
customFieldMutation.removeUsages(backgroundRundown, key);
updateBackgroundRundown(rundownId, backgroundRundown);
updateBackgroundRundown(rundown.id, backgroundRundown);
}
}
+94
View File
@@ -0,0 +1,94 @@
> ontime@4.3.1 dev /app
> turbo run dev
Attention:
Turborepo now collects completely anonymous telemetry regarding usage.
This information is used to shape the Turborepo roadmap and prioritize features.
You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
https://turborepo.com/docs/telemetry
turbo 2.5.8
• Packages in scope: @getontime/cli, @getontime/resolver, ontime-electron, ontime-server, ontime-types, ontime-ui, ontime-utils
• Running dev in 7 packages
• Remote caching disabled
ontime-server:dev: cache bypass, force executing 736e67d68a6b5681
ontime-ui:dev: cache bypass, force executing 9eacdb30152cd80e
ontime-ui:dev:
ontime-ui:dev: > ontime-ui@4.3.1 dev /app/apps/client
ontime-ui:dev: > cross-env BROWSER=none vite
ontime-ui:dev:
ontime-server:dev:
ontime-server:dev: > ontime-server@4.3.1 dev /app/apps/server
ontime-server:dev: > cross-env NODE_ENV=development tsx watch ./src/index.ts
ontime-server:dev:
ontime-ui:dev:
ontime-ui:dev: VITE v6.3.1 ready in 748 ms
ontime-ui:dev:
ontime-ui:dev: ➜ Local: http://localhost:3000/
ontime-ui:dev: ➜ Network: use --host to expose
ontime-server:dev:
ontime-server:dev:
ontime-server:dev: Starting Ontime version 4.3.1
ontime-server:dev: Ontime running in development environment
ontime-server:dev: Ontime source directory at /app/apps/server/src/
ontime-server:dev: Ontime public directory at /home/jules/.Ontime
ontime-server:dev:
ontime-server:dev:
ontime-server:dev: Request: Initialise assets...
ontime-server:dev: [INFO] SERVER Loaded project demo project.json
ontime-server:dev: [INFO] SERVER Switch to rundown: default
ontime-server:dev: [INFO] SERVER Initialised Ontime with demo project.json
ontime-server:dev:
ontime-server:dev:
ontime-server:dev: Request: Start server...
ontime-server:dev: [INFO] SERVER Runtime service started
ontime-server:dev: Local: http://localhost:4001/editor
ontime-server:dev: Network: http://192.168.0.2:4001/editor
ontime-server:dev: [INFO] SERVER Ontime is listening on port 4001
ontime-server:dev:
ontime-server:dev:
ontime-server:dev: Request: Start integrations...
ontime-server:dev: [INFO] SERVER Skipping OSC integration
ontime-ui:dev: Browserslist: browsers data (caniuse-lite) is 9 months old. Please run:
ontime-ui:dev: npx update-browserslist-db@latest
ontime-ui:dev: Why you should do it regularly: https://github.com/browserslist/update-db#readme
ontime-ui:dev: Browserslist: caniuse-lite is outdated. Please run:
ontime-ui:dev: npx update-browserslist-db@latest
ontime-ui:dev: Why you should do it regularly: https://github.com/browserslist/update-db#readme
ontime-server:dev: [INFO] CLIENT 1 Connections with new: low-fi instrument
ontime-ui:dev: Proxy: GET /data/settings
ontime-ui:dev: Proxy: GET /user/translations/translations.json
ontime-ui:dev: Proxy: GET /data/report
ontime-ui:dev: Proxy: GET /data/view-settings
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/db/all
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/db/all
ontime-server:dev: [INFO] CLIENT 0 Connections with disconnected: low-fi instrument
ontime-server:dev: [INFO] CLIENT 1 Connections with new: mellow uplight
ontime-ui:dev: Proxy: GET /data/settings
ontime-ui:dev: Proxy: GET /user/translations/translations.json
ontime-ui:dev: Proxy: GET /data/report
ontime-ui:dev: Proxy: GET /data/view-settings
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-server:dev: [INFO] CLIENT 0 Connections with disconnected: mellow uplight
ontime-server:dev: [INFO] CLIENT 1 Connections with new: nostalgic mix
ontime-ui:dev: Proxy: GET /data/settings
ontime-ui:dev: Proxy: GET /user/translations/translations.json
ontime-ui:dev: Proxy: GET /data/report
ontime-ui:dev: Proxy: GET /data/view-settings
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/custom-fields
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-server:dev: [INFO] CLIENT 0 Connections with disconnected: nostalgic mix
+6 -53
View File
@@ -1,57 +1,10 @@
import { expect, test } from '@playwright/test';
test('cuesheet displays events', async ({ page }) => {
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);
// 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();
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.4.0",
"version": "4.3.1",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.4.0",
"version": "4.3.1",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",