From b048d0f88d1f4a2b5f16b0665c78049361476719 Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Mon, 16 Dec 2024 14:27:25 +0100 Subject: [PATCH] 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> --- apps/client/src/theme/ontimeTextInputs.ts | 10 +++ apps/client/src/theme/theme.ts | 2 + apps/client/src/views/cuesheet/Cuesheet.tsx | 22 +++++- .../src/views/cuesheet/CuesheetPage.tsx | 60 ++++++++------ .../cuesheet-table-elements/EditableCell.tsx | 55 ------------- .../cuesheet-table-elements/MultiLineCell.tsx | 37 +++++++++ .../SingleLineCell.tsx | 35 ++++++++ .../src/views/cuesheet/cuesheetCols.tsx | 79 ++++++++++++++++--- e2e/tests/features/202-cuesheet.spec.ts | 21 +++-- 9 files changed, 223 insertions(+), 98 deletions(-) delete mode 100644 apps/client/src/views/cuesheet/cuesheet-table-elements/EditableCell.tsx create mode 100644 apps/client/src/views/cuesheet/cuesheet-table-elements/MultiLineCell.tsx create mode 100644 apps/client/src/views/cuesheet/cuesheet-table-elements/SingleLineCell.tsx diff --git a/apps/client/src/theme/ontimeTextInputs.ts b/apps/client/src/theme/ontimeTextInputs.ts index 0547b58db..ec96e7fba 100644 --- a/apps/client/src/theme/ontimeTextInputs.ts +++ b/apps/client/src/theme/ontimeTextInputs.ts @@ -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, }; diff --git a/apps/client/src/theme/theme.ts b/apps/client/src/theme/theme.ts index ee6b00e46..ff68d5177 100644 --- a/apps/client/src/theme/theme.ts +++ b/apps/client/src/theme/theme.ts @@ -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: { diff --git a/apps/client/src/views/cuesheet/Cuesheet.tsx b/apps/client/src/views/cuesheet/Cuesheet.tsx index 7c4eb412e..a77c36da3 100644 --- a/apps/client/src/views/cuesheet/Cuesheet.tsx +++ b/apps/client/src/views/cuesheet/Cuesheet.tsx @@ -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[]; - 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, diff --git a/apps/client/src/views/cuesheet/CuesheetPage.tsx b/apps/client/src/views/cuesheet/CuesheetPage.tsx index df49c8499..2ab1ccff6 100644 --- a/apps/client/src/views/cuesheet/CuesheetPage.tsx +++ b/apps/client/src/views/cuesheet/CuesheetPage.tsx @@ -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} /> diff --git a/apps/client/src/views/cuesheet/cuesheet-table-elements/EditableCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table-elements/EditableCell.tsx deleted file mode 100644 index 301b60f4b..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-table-elements/EditableCell.tsx +++ /dev/null @@ -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(); - const onChange = useCallback((event: ChangeEvent) => 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 ( - - ); -}; - -export default memo(EditableCell); diff --git a/apps/client/src/views/cuesheet/cuesheet-table-elements/MultiLineCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table-elements/MultiLineCell.tsx new file mode 100644 index 000000000..9fa159863 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table-elements/MultiLineCell.tsx @@ -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(null); + const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]); + + const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, { + submitOnCtrlEnter: true, + }); + + return ( + + ); +}; + +export default memo(MultiLineCell); diff --git a/apps/client/src/views/cuesheet/cuesheet-table-elements/SingleLineCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table-elements/SingleLineCell.tsx new file mode 100644 index 000000000..329d8a4f9 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table-elements/SingleLineCell.tsx @@ -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(null); + const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]); + + const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, { + submitOnCtrlEnter: true, + }); + + return ( + + ); +}; + +export default memo(SingleLineCell); diff --git a/apps/client/src/views/cuesheet/cuesheetCols.tsx b/apps/client/src/views/cuesheet/cuesheetCols.tsx index 42b91ddc0..460d7061d 100644 --- a/apps/client/src/views/cuesheet/cuesheetCols.tsx +++ b/apps/client/src/views/cuesheet/cuesheetCols.tsx @@ -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) { - const cellValue = row.getValue(); - return cellValue ? : ''; +function MakePublic({ row, column, table }: CellContext) { + const update = useCallback( + (event: React.ChangeEvent) => { + // @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 ( + + ); } function MakeTimer({ getValue, row: { original } }: CellContext) { @@ -40,7 +58,7 @@ function MakeDuration({ getValue }: CellContext) { return ; } -function MakeCustomField({ row, column, table }: CellContext) { +function MakeMultiLineField({ row, column, table }: CellContext) { const update = useCallback( (newValue: string) => { // @ts-expect-error -- we inject this into react-table @@ -55,10 +73,49 @@ function MakeCustomField({ row, column, table }: CellContext; +} + +function MakeSingleLineField({ row, column, table }: CellContext) { + 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 ; +} + +function MakeCustomField({ row, column, table }: CellContext) { + 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 ; + return ; } export function makeCuesheetColumns(customFields: CustomFields): ColumnDef[] { @@ -83,7 +140,7 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef row.getValue(), + cell: MakeSingleLineField, size: 250, }, { accessorKey: 'note', id: 'note', header: 'Note', - cell: (row) => row.getValue(), + cell: MakeMultiLineField, size: 250, }, ...dynamicCustomFields, diff --git a/e2e/tests/features/202-cuesheet.spec.ts b/e2e/tests/features/202-cuesheet.spec.ts index c4f23e4f9..df98815e7 100644 --- a/e2e/tests/features/202-cuesheet.spec.ts +++ b/e2e/tests/features/202-cuesheet.spec.ts @@ -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', + ); });