mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-06 16:03:52 +00:00
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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
|
||||
import { useTableNav } from '@table-nav/react';
|
||||
import { ColumnDef, getCoreRowModel, 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';
|
||||
@@ -27,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;
|
||||
@@ -49,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;
|
||||
@@ -84,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);
|
||||
@@ -171,6 +283,17 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
||||
const allLeafColumns = table.getAllLeafColumns();
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const virtuosoContext = useMemo(
|
||||
() => ({
|
||||
table,
|
||||
rows,
|
||||
cursor,
|
||||
columnSizeVars,
|
||||
listeners,
|
||||
}),
|
||||
[columnSizeVars, cursor, listeners, rows, table],
|
||||
);
|
||||
|
||||
const isLoading = !data || status === 'pending';
|
||||
|
||||
if (isLoading) {
|
||||
@@ -191,99 +314,14 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
context={virtuosoContext}
|
||||
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
|
||||
increaseViewportBy={{ top: 100, bottom: 200 }}
|
||||
components={{
|
||||
EmptyPlaceholder: () => <EmptyTableBody text='No data in rundown' />,
|
||||
Table: ({ style: injectedStyles, ...virtuosoProps }) => {
|
||||
return (
|
||||
<table
|
||||
className={style.cuesheet}
|
||||
id='cuesheet'
|
||||
style={{ ...injectedStyles, ...columnSizeVars }}
|
||||
{...listeners}
|
||||
{...virtuosoProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
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 (
|
||||
<GroupRow
|
||||
key={key}
|
||||
groupId={entry.id}
|
||||
colour={entry.colour}
|
||||
rowId={row.id}
|
||||
rowIndex={row.index}
|
||||
table={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={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={table}
|
||||
injectedStyles={injectedStyles}
|
||||
hasCursor={hasCursor}
|
||||
{...virtuosoProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
TableHead: (virtuosoProps) => <thead className={style.tableHeader} {...virtuosoProps} />,
|
||||
EmptyPlaceholder: VirtuosoEmptyPlaceholder,
|
||||
Table: VirtuosoTable,
|
||||
TableRow: VirtuosoTableRow,
|
||||
TableHead: VirtuosoTableHead,
|
||||
}}
|
||||
fixedHeaderContent={() => {
|
||||
return table.getHeaderGroups().map((headerGroup) => {
|
||||
|
||||
@@ -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: [96mStarting Ontime version 4.3.1[0m
|
||||
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: [96mRequest: Initialise assets...[0m
|
||||
ontime-server:dev: [2m[INFO] SERVER Loaded project demo project.json[0m
|
||||
ontime-server:dev: [2m[INFO] SERVER Switch to rundown: default[0m
|
||||
ontime-server:dev: [2m[INFO] SERVER Initialised Ontime with demo project.json[0m
|
||||
ontime-server:dev:
|
||||
ontime-server:dev:
|
||||
ontime-server:dev: [96mRequest: Start server...[0m
|
||||
ontime-server:dev: [2m[INFO] SERVER Runtime service started[0m
|
||||
ontime-server:dev: [32mLocal: http://localhost:4001/editor[0m
|
||||
ontime-server:dev: [32mNetwork: http://192.168.0.2:4001/editor[0m
|
||||
ontime-server:dev: [2m[INFO] SERVER Ontime is listening on port 4001[0m
|
||||
ontime-server:dev:
|
||||
ontime-server:dev:
|
||||
ontime-server:dev: [96mRequest: Start integrations...[0m
|
||||
ontime-server:dev: [2m[INFO] SERVER Skipping OSC integration[0m
|
||||
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: [2m[INFO] CLIENT 1 Connections with new: low-fi instrument[0m
|
||||
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: [2m[INFO] CLIENT 0 Connections with disconnected: low-fi instrument[0m
|
||||
ontime-server:dev: [2m[INFO] CLIENT 1 Connections with new: mellow uplight[0m
|
||||
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: [2m[INFO] CLIENT 0 Connections with disconnected: mellow uplight[0m
|
||||
ontime-server:dev: [2m[INFO] CLIENT 1 Connections with new: nostalgic mix[0m
|
||||
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: [2m[INFO] CLIENT 0 Connections with disconnected: nostalgic mix[0m
|
||||
Reference in New Issue
Block a user