Edit in cuesheet (#1372)

* pass on all event edits

* MakePublic

* trim value in cell

* edit notes

* title

* add key check

* don't log error

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* checkbox

* refactor

* remove log

* fix test

* use row number to test value

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
This commit is contained in:
Alex Christoffer Rasmussen
2024-12-16 14:27:25 +01:00
committed by GitHub
parent d8f7d4bba6
commit b048d0f88d
9 changed files with 223 additions and 98 deletions
+10
View File
@@ -38,6 +38,16 @@ export const ontimeInputGhosted = {
},
};
export const ontimeInputTransparent = {
field: {
...commonStyles,
backgroundColor: 'transparent',
_hover: {
backgroundColor: 'rgba(255, 255, 255, 0.10)', // $white-10
},
},
};
export const ontimeTextAreaFilled = {
...commonStyles,
};
+2
View File
@@ -21,6 +21,7 @@ import { ontimeTab } from './ontimeTab';
import {
ontimeInputFilled,
ontimeInputGhosted,
ontimeInputTransparent,
ontimeTextAreaFilled,
ontimeTextAreaTransparent,
} from './ontimeTextInputs';
@@ -78,6 +79,7 @@ const theme = extendTheme({
variants: {
'ontime-filled': { ...ontimeInputFilled },
'ontime-ghosted': { ...ontimeInputGhosted },
'ontime-transparent': { ...ontimeInputTransparent },
},
},
Kbd: {
+19 -3
View File
@@ -1,7 +1,14 @@
import { useCallback, useRef } from 'react';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import Color from 'color';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import {
CustomFieldLabel,
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
OntimeRundown,
OntimeRundownEntry,
} from 'ontime-types';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { getAccessibleColour } from '../../common/utils/styleUtils';
@@ -19,12 +26,20 @@ import style from './Cuesheet.module.scss';
interface CuesheetProps {
data: OntimeRundown;
columns: ColumnDef<OntimeRundownEntry>[];
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => void;
handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: string) => void;
handleUpdateCustom: (rowIndex: number, accessor: CustomFieldLabel, payload: string) => void;
selectedId: string | null;
currentBlockId: string | null;
}
export default function Cuesheet({ data, columns, handleUpdate, selectedId, currentBlockId }: CuesheetProps) {
export default function Cuesheet({
data,
columns,
handleUpdate,
handleUpdateCustom,
selectedId,
currentBlockId,
}: CuesheetProps) {
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
const {
columnVisibility,
@@ -51,6 +66,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId, curr
},
meta: {
handleUpdate,
handleUpdateCustom,
},
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
+38 -22
View File
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { CustomFieldLabel, isOntimeEvent } from 'ontime-types';
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import EmptyPage from '../../common/components/state/EmptyPage';
@@ -26,7 +26,7 @@ export default function CuesheetPage() {
const { data: customFields } = useCustomFields();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { updateCustomField } = useEventAction();
const { updateCustomField, updateEvent } = useEventAction();
const featureData = useCuesheet();
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
@@ -34,11 +34,10 @@ export default function CuesheetPage() {
useWindowTitle('Cuesheet');
/**
* Handles updating a field
* Currently, only custom fields can be updated from the cuesheet
* Handles updating a custom field
*/
const handleUpdate = useCallback(
async (rowIndex: number, accessor: CustomFieldLabel, payload: unknown) => {
const handleUpdateCustom = useCallback(
async (rowIndex: number, accessor: CustomFieldLabel, payload: string) => {
if (!flatRundown || rundownStatus !== 'success') {
return;
}
@@ -53,29 +52,44 @@ export default function CuesheetPage() {
return;
}
// skip if there is no value change
const previousValue = event.custom[accessor];
if (previousValue === payload) {
return;
}
updateCustomField(event.id, accessor, payload);
},
[flatRundown, rundownStatus, updateCustomField],
);
/**
* Handles updating all other string fields
*/
const handleUpdate = useCallback(
async (rowIndex: number, accessor: keyof OntimeEvent, payload: string) => {
if (!flatRundown || rundownStatus !== 'success') {
return;
}
if (rowIndex == null || accessor == null || payload == null) {
return;
}
// check if value is the same
const event = flatRundown[rowIndex];
if (!event || !isOntimeEvent(event)) {
return;
}
// skip if there is no value change
const previousValue = event[accessor];
if (previousValue === payload) {
return;
}
// check if value is valid
// in anticipation to different types of event here
if (typeof payload !== 'string') {
return;
}
// cleanup
const cleanVal = payload.trim();
// submit
try {
await updateCustomField(event.id, accessor, cleanVal);
} catch (error) {
console.error(error);
}
updateEvent({ id: event.id, [accessor]: payload });
},
[flatRundown, rundownStatus, updateCustomField],
[flatRundown, rundownStatus, updateEvent],
);
if (!customFields || !flatRundown || rundownStatus !== 'success') {
@@ -106,6 +120,8 @@ export default function CuesheetPage() {
data={flatRundown}
columns={columns}
handleUpdate={handleUpdate}
handleUpdateCustom={handleUpdateCustom}
//TODO: stabilizer selectedEventId and currentBlockId
selectedId={featureData.selectedEventId}
currentBlockId={featureData.currentBlockId}
/>
@@ -1,55 +0,0 @@
import { ChangeEvent, memo, useCallback, useEffect, useRef, useState } from 'react';
import { getHotkeyHandler } from '@mantine/hooks';
import { AutoTextArea } from '../../../common/components/input/auto-text-area/AutoTextArea';
interface EditableCellProps {
value: string;
handleUpdate: (newValue: string) => void;
}
const EditableCell = (props: EditableCellProps) => {
const { value: initialValue, handleUpdate } = props;
// We need to keep and update the state of the cell normally
const [value, setValue] = useState(initialValue);
const ref = useRef<HTMLAreaElement>();
const onChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => setValue(event.target.value), []);
// We'll only update the external data when the input is blurred
const onBlur = useCallback(() => handleUpdate(value), [handleUpdate, value]);
//TODO: maybe we can unify this with `useReactiveTextInput`
const onKeyDown = getHotkeyHandler([
['mod + Enter', () => ref.current?.blur()],
[
'Escape',
() => {
setValue(initialValue);
setTimeout(() => ref.current?.blur());
},
],
]);
// If the initialValue is changed external, sync it up with our state
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
return (
<AutoTextArea
size='sm'
value={value}
inputref={ref}
onChange={onChange}
onBlur={onBlur}
rows={1}
onKeyDown={onKeyDown}
transition='none'
spellCheck={false}
style={{ padding: 0 }}
/>
);
};
export default memo(EditableCell);
@@ -0,0 +1,37 @@
import { memo, useCallback, useRef } from 'react';
import { AutoTextArea } from '../../../common/components/input/auto-text-area/AutoTextArea';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
interface MultiLineCellProps {
initialValue: string;
handleUpdate: (newValue: string) => void;
}
const MultiLineCell = (props: MultiLineCellProps) => {
const { initialValue, handleUpdate } = props;
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
submitOnCtrlEnter: true,
});
return (
<AutoTextArea
inputref={ref}
rows={1}
size='sm'
style={{ padding: 0 }}
transition='none'
variant='ontime-transparent'
value={value}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
spellCheck={false}
/>
);
};
export default memo(MultiLineCell);
@@ -0,0 +1,35 @@
import { memo, useCallback, useRef } from 'react';
import { Input } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
interface SingleLineCellProps {
initialValue: string;
handleUpdate: (newValue: string) => void;
}
const SingleLineCell = (props: SingleLineCellProps) => {
const { initialValue, handleUpdate } = props;
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
submitOnCtrlEnter: true,
});
return (
<Input
ref={ref}
size='sx'
variant='ontime-transparent'
value={value}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
spellCheck={false}
autoComplete='off'
/>
);
};
export default memo(SingleLineCell);
+68 -11
View File
@@ -1,19 +1,37 @@
import { useCallback } from 'react';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { Checkbox } from '@chakra-ui/react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
import RunningTime from '../../features/viewers/common/running-time/RunningTime';
import EditableCell from './cuesheet-table-elements/EditableCell';
import MultiLineCell from './cuesheet-table-elements/MultiLineCell';
import SingleLineCell from './cuesheet-table-elements/SingleLineCell';
import { useCuesheetSettings } from './store/cuesheetSettingsStore';
import style from './Cuesheet.module.scss';
function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
const cellValue = row.getValue();
return cellValue ? <IoCheckmark className={style.check} /> : '';
function MakePublic({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
const update = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
// @ts-expect-error -- we inject this into react-table
table.options.meta?.handleUpdate(row.index, column.id, event.target.checked);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
);
const event = row.original;
if (!isOntimeEvent(event)) {
return null;
}
const isChecked = event.isPublic;
return (
<Checkbox variant='ontime-ondark' onChange={update} isChecked={isChecked} style={{ verticalAlign: 'middle' }} />
);
}
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
@@ -40,7 +58,7 @@ function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
return <RunningTime value={cellValue} hideSeconds={hideSeconds} />;
}
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
// @ts-expect-error -- we inject this into react-table
@@ -55,10 +73,49 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry,
return null;
}
// events dont necessarily contain all custom fields
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? '';
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
}
function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
// @ts-expect-error -- we inject this into react-table
table.options.meta?.handleUpdate(row.index, column.id, newValue);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
);
const event = row.original;
if (!isOntimeEvent(event)) {
return null;
}
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? '';
return <SingleLineCell initialValue={initialValue} handleUpdate={update} />;
}
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
// @ts-expect-error -- we inject this into react-table
table.options.meta?.handleUpdateCustom(row.index, column.id, newValue);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
);
const event = row.original;
if (!isOntimeEvent(event)) {
return null;
}
const initialValue = event.custom[column.id] ?? '';
return <EditableCell value={initialValue} handleUpdate={update} />;
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
}
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] {
@@ -83,7 +140,7 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
accessorKey: 'isPublic',
id: 'isPublic',
header: 'Public',
cell: makePublic,
cell: MakePublic,
size: 45,
},
{
@@ -111,14 +168,14 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
accessorKey: 'title',
id: 'title',
header: 'Title',
cell: (row) => row.getValue(),
cell: MakeSingleLineField,
size: 250,
},
{
accessorKey: 'note',
id: 'note',
header: 'Note',
cell: (row) => row.getValue(),
cell: MakeMultiLineField,
size: 250,
},
...dynamicCustomFields,
+14 -7
View File
@@ -1,11 +1,18 @@
import { test } from '@playwright/test';
import { expect, test } from '@playwright/test';
test('cuesheet displays events and exports csv', async ({ page }) => {
test('cuesheet displays events', async ({ page }) => {
// same elements in cuesheet
await page.goto('http://localhost:4001/cuesheet');
await page.getByText('Eurovision Song Contest').click();
await page.getByRole('cell', { name: 'Lunch break' }).click();
await page.getByRole('cell', { name: 'Albania' }).click();
await page.getByRole('cell', { name: 'Latvia' }).click();
await page.getByRole('cell', { name: 'Lithuania' }).click();
await expect(page.getByText('Eurovision Song Contest')).toBeVisible();
await expect(page.getByRole('row', { name: 'Lunch break' })).toBeVisible();
await expect(page.locator('tr:nth-child(1) > td:nth-child(7)').first().getByRole('textbox').first()).toHaveValue(
'Albania',
);
await expect(page.locator('tr:nth-child(2) > td:nth-child(7)').first().getByRole('textbox').first()).toHaveValue(
'Latvia',
);
await expect(page.locator('tr:nth-child(3) > td:nth-child(7)').first().getByRole('textbox').first()).toHaveValue(
'Lithuania',
);
});