diff --git a/apps/client/src/common/components/buttons/BaseButtonStyles.module.scss b/apps/client/src/common/components/buttons/BaseButtonStyles.module.scss index 7a6fac43d..73f442121 100644 --- a/apps/client/src/common/components/buttons/BaseButtonStyles.module.scss +++ b/apps/client/src/common/components/buttons/BaseButtonStyles.module.scss @@ -79,3 +79,22 @@ border-color: $gray-1250; } } + +.ghosted { + background: transparent; + color: $ui-white; + + &:hover:not(:disabled):not(:active) { + background: $gray-1000; + color: $ui-white; + } + + &:active:not(:disabled) { + background: $gray-1100; + border-color: $gray-1250; + } + + &:disabled { + opacity: $opacity-disabled; + } +} \ No newline at end of file diff --git a/apps/client/src/common/components/buttons/Button.tsx b/apps/client/src/common/components/buttons/Button.tsx index f1a2cf624..f6e85d700 100644 --- a/apps/client/src/common/components/buttons/Button.tsx +++ b/apps/client/src/common/components/buttons/Button.tsx @@ -1,20 +1,21 @@ -import { ButtonHTMLAttributes } from 'react'; +import { ButtonHTMLAttributes, forwardRef } from 'react'; import { cx } from '../../utils/styleUtils'; import style from './Button.module.scss'; interface ButtonProps extends ButtonHTMLAttributes { - variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive'; + variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive' | 'ghosted'; size?: 'small' | 'medium' | 'large' | 'xlarge'; fluid?: boolean; } -export default function Button(props: ButtonProps) { +const Button = forwardRef((props, ref) => { const { className, children, variant = 'subtle', size = 'medium', fluid, ...buttonProps } = props; return ( + + diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.module.scss new file mode 100644 index 000000000..deefcf803 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.module.scss @@ -0,0 +1,40 @@ +@import "../CuesheetTable.module.scss"; + +.eventRow { + vertical-align: top; + background: color-mix(in srgb, transparent 92%, var(--user-bg, $gray-500) 8%); + border-left: 4px solid var(--user-bg, $gray-500); + + &:hover { + outline: 1px solid $blue-500; + outline-offset: -1px; + background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%); + } + + &.firstAfterBlock { + margin-top: 1rem; + } + + &.skip { + position: relative; + + &::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient( + -45deg, + rgba(255, 255, 255, 0.03), + rgba(255, 255, 255, 0.03) 10px, + rgba(255, 255, 255, 0.08) 10px, + rgba(255, 255, 255, 0.08) 20px + ); + } + } + + td { + background-color: $gray-1200; + border-radius: 2px; + } +} 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 d2db1b4f4..552fa74de 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 @@ -1,4 +1,4 @@ -import { memo, MutableRefObject, useLayoutEffect, useRef, useState } from 'react'; +import { memo, MutableRefObject, useLayoutEffect, useRef } from 'react'; import { IoEllipsisHorizontal } from 'react-icons/io5'; import { flexRender, Table } from '@tanstack/react-table'; import { OntimeEntry, OntimeEvent, RGBColour } from 'ontime-types'; @@ -6,10 +6,12 @@ import { colourToHex, cssOrHexToColour } from 'ontime-utils'; import IconButton from '../../../../common/components/buttons/IconButton'; import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; -import { useCuesheetOptions } from '../../cuesheet.options'; +import { usePersistedCuesheetOptions } from '../../cuesheet.options'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; -import style from '../CuesheetTable.module.scss'; +import { useVisibleRowsStore } from './visibleRowsStore'; + +import style from './EventRow.module.scss'; interface EventRowProps { rowId: string; @@ -21,9 +23,12 @@ interface EventRowProps { skip?: boolean; colour?: string; rowBgColour?: string; + parentBgColour?: string; table: Table; /** hack to force re-rendering of the row when the column sizes change */ columnHash: string; + observer: IntersectionObserver; + firstAfterBlock: boolean; } export default memo(EventRow, (prevProps, nextProps) => { @@ -35,34 +40,36 @@ export default memo(EventRow, (prevProps, nextProps) => { prevProps.isPast === nextProps.isPast && prevProps.selectedRef === nextProps.selectedRef && prevProps.rowBgColour === nextProps.rowBgColour && + prevProps.parentBgColour === nextProps.parentBgColour && prevProps.columnHash === nextProps.columnHash ); }); -function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, rowBgColour, table }: EventRowProps) { - const { hideIndexColumn, showActionMenu } = useCuesheetOptions(); +function EventRow({ + rowId, + event, + eventIndex, + rowIndex, + isPast, + selectedRef, + rowBgColour, + parentBgColour, + table, + observer, + firstAfterBlock, +}: EventRowProps) { + const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn); + const showActionMenu = usePersistedCuesheetOptions((state) => state.showActionMenu); const ownRef = useRef(null); - const [isVisible, setIsVisible] = useState(false); + + const isVisible = useVisibleRowsStore((state) => state.visibleRows.has(rowId)); const openMenu = useCuesheetTableMenu((store) => store.openMenu); + // store a reference of the row in the observer useLayoutEffect(() => { - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - setIsVisible(true); - } - }, - { - root: null, - threshold: 0.01, - }, - ); - const handleRefCurrent = ownRef.current; - if (selectedRef) { - setIsVisible(true); - } else if (handleRefCurrent) { + if (handleRefCurrent) { observer.observe(handleRefCurrent); } @@ -71,22 +78,28 @@ function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, row observer.unobserve(handleRefCurrent); } }; - }, [ownRef, selectedRef]); + }, [observer]); const { color, backgroundColor } = getAccessibleColour(event.colour); const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour - const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.6 }); + const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 }); return ( {showActionMenu && ( { const rect = e.currentTarget.getBoundingClientRect(); const yPos = 8 + rect.y + rect.height / 2; @@ -105,12 +118,15 @@ function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, row {isVisible ? table .getRow(rowId) - ?.getVisibleCells() + .getVisibleCells() .map((cell) => { return ( diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MutedText.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MutedText.module.scss new file mode 100644 index 000000000..134583b15 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MutedText.module.scss @@ -0,0 +1,9 @@ +.muted { + opacity: 0.4; // same as the time input with muted text + line-height: 2rem; // input height +} + +.numeric { + font-variant-numeric: tabular-nums; + letter-spacing: 0.5px; +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MutedText.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MutedText.tsx new file mode 100644 index 000000000..e0b69a582 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MutedText.tsx @@ -0,0 +1,13 @@ +import { PropsWithChildren } from 'react'; + +import { cx } from '../../../../common/utils/styleUtils'; + +import style from './MutedText.module.scss'; + +interface MutedTextProps { + numeric?: boolean; +} + +export default function MutedText({ numeric, children }: PropsWithChildren) { + return {children}; +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx index 9ccf3f20f..9f7753b44 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx @@ -4,15 +4,15 @@ import { CSS } from '@dnd-kit/utilities'; import { Header } from '@tanstack/react-table'; import { OntimeEntry } from 'ontime-types'; -import styles from '../CuesheetTable.module.scss'; +import style from '../CuesheetTable.module.scss'; interface SortableCellProps { header: Header; - style: CSSProperties; + injectedStyles: CSSProperties; children: ReactNode; } -export function SortableCell({ header, style, children }: SortableCellProps) { +export function SortableCell({ header, injectedStyles, children }: SortableCellProps) { const { column, colSpan } = header; const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ @@ -21,7 +21,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) { // build drag styles const dragStyle = { - ...style, + ...injectedStyles, opacity: isDragging ? 0.5 : 1, transform: CSS.Translate.toString(transform), transition, @@ -34,10 +34,11 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
header.column.resetSize(), onMouseDown: header.getResizeHandler(), onTouchStart: header.getResizeHandler(), }} - className={styles.resizer} + className={style.resizer} /> ); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput.module.scss index c37a990af..ae189f55a 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput.module.scss +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput.module.scss @@ -7,10 +7,12 @@ display: flex; align-items: center; gap: 0.25rem; - letter-spacing: 1px; + letter-spacing: 0.5px; font-size: 1rem; font-variant-numeric: tabular-nums; + overflow: hidden; + &.delayed { color: $ontime-delay-text; } diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx similarity index 72% rename from apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx rename to apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx index 19d4379fe..55d1e759c 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { CellContext, ColumnDef } from '@tanstack/react-table'; -import { CustomFields, isOntimeEvent, OntimeEntry, OntimeEvent, TimeStrategy } from 'ontime-types'; +import { CustomFields, isOntimeDelay, isOntimeEvent, OntimeEntry, TimeStrategy } from 'ontime-types'; import { millisToString } from 'ontime-utils'; import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; @@ -9,6 +9,7 @@ import { formatDuration, formatTime } from '../../../../common/utils/time'; import DurationInput from './DurationInput'; import EditableImage from './EditableImage'; import MultiLineCell from './MultiLineCell'; +import MutedText from './MutedText'; import SingleLineCell from './SingleLineCell'; import TimeInput from './TimeInput'; @@ -17,24 +18,29 @@ function MakeStart({ getValue, row, table }: CellContext) return null; } - const { handleUpdateTimer } = table.options.meta; const { showDelayedTimes, hideTableSeconds } = table.options.meta.options; + const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined; + + const event = row.original; + if (!isOntimeEvent(event)) { + return {formatTime(getValue() as number)}; + } + + const { handleUpdateTimer } = table.options.meta; const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeStart', newValue); const startTime = getValue() as number; - const isStartLocked = !(row.original as OntimeEvent).linkStart; - const delayValue = (row.original as OntimeEvent)?.delay ?? 0; + const isStartLocked = !event.linkStart; - const displayTime = showDelayedTimes ? startTime + delayValue : startTime; + const displayTime = showDelayedTimes ? startTime + event.delay : startTime; - const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined; const formattedTime = formatTime(displayTime, formatOpts); return ( - + {formattedTime} - + ); } @@ -44,24 +50,29 @@ function MakeEnd({ getValue, row, table }: CellContext) { return null; } - const { handleUpdateTimer } = table.options.meta; const { showDelayedTimes, hideTableSeconds } = table.options.meta.options; + const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined; + + const event = row.original; + if (!isOntimeEvent(event)) { + return {formatTime(getValue() as number, formatOpts)}; + } + + const { handleUpdateTimer } = table.options.meta; const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeEnd', newValue); const endTime = getValue() as number; - const isEndLocked = (row.original as OntimeEvent).timeStrategy === TimeStrategy.LockEnd; - const delayValue = (row.original as OntimeEvent)?.delay ?? 0; + const isEndLocked = event.timeStrategy === TimeStrategy.LockEnd; - const displayTime = showDelayedTimes ? endTime + delayValue : endTime; + const displayTime = showDelayedTimes ? endTime + event.delay : endTime; - const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined; const formattedTime = formatTime(displayTime, formatOpts); return ( - + {formattedTime} - + ); } @@ -71,13 +82,19 @@ function MakeDuration({ getValue, row, table }: CellContext{formatDuration(getValue() as number, hideTableSeconds)}; + } + const { handleUpdateTimer } = table.options.meta; const update = (newValue: string) => handleUpdateTimer(row.original.id, 'duration', newValue); const duration = getValue() as number; - const isDurationLocked = (row.original as OntimeEvent).timeStrategy === TimeStrategy.LockDuration; - const formattedDuration = formatDuration(duration, false); + const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration; + const formattedDuration = formatDuration(duration, hideTableSeconds); return ( @@ -91,17 +108,15 @@ function MakeMultiLineField({ row, column, table }: CellContext { table.options.meta?.handleUpdate(row.index, column.id, newValue, false); }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable - [column.id, row.index], + [column.id, row.index, table.options.meta], ); - const event = row.original; - if (!isOntimeEvent(event)) { + // not all entries have all properties (eg blocks) + const initialValue = row.original[column.id as keyof OntimeEntry]; + if (initialValue === undefined) { return null; } - const initialValue = event[column.id as keyof OntimeEntry] ?? ''; - return ; } @@ -110,12 +125,11 @@ function LazyImage({ row, column, table }: CellContext) { (newValue: string) => { table.options.meta?.handleUpdate(row.index, column.id, newValue, true); }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable - [column.id, row.index], + [column.id, row.index, table.options.meta], ); const event = row.original; - if (!isOntimeEvent(event)) { + if (isOntimeDelay(event)) { return null; } @@ -128,17 +142,15 @@ function MakeSingleLineField({ row, column, table }: CellContext { table.options.meta?.handleUpdate(row.index, column.id, newValue, false); }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable - [column.id, row.index], + [column.id, row.index, table.options.meta], ); - const event = row.original; - if (!isOntimeEvent(event)) { + // not all entries have all properties (eg blocks) + const initialValue = row.original[column.id as keyof OntimeEntry]; + if (initialValue === undefined) { return null; } - const initialValue = event[column.id as keyof OntimeEntry] ?? ''; - return ; } @@ -147,20 +159,25 @@ function MakeCustomField({ row, column, table }: CellContext { table.options.meta?.handleUpdate(row.index, column.id, newValue, true); }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable - [column.id, row.index], + [column.id, row.index, table.options.meta], ); const event = row.original; - if (!isOntimeEvent(event)) { + if (isOntimeDelay(event)) { return null; } + // fields will not contain the field if there is no value set by the user + // event if there is no initial value, we still render the cell const initialValue = event.custom[column.id] ?? ''; return ; } export function makeCuesheetColumns(customFields: CustomFields): ColumnDef[] { + /** + * we cant use the createColumnHelper() because we have custom logic for rendering the cells + * This means that the display columns: index and action are added inline by the row components + */ const dynamicCustomFields = Object.keys(customFields).map((key) => ({ accessorKey: key, id: key, diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/visibleRowsStore.ts b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/visibleRowsStore.ts new file mode 100644 index 000000000..fc20bf3a2 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/visibleRowsStore.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand'; + +interface VisibleRowsStore { + visibleRows: Set; + addVisibleRow: (id: string) => void; + removeVisibleRow: (id: string) => void; +} + +export const useVisibleRowsStore = create((set) => ({ + visibleRows: new Set(), + addVisibleRow: (id) => set((state) => ({ visibleRows: new Set(state.visibleRows).add(id) })), + removeVisibleRow: (id) => + set((state) => { + const newSet = new Set(state.visibleRows); + newSet.delete(id); + return { visibleRows: newSet }; + }), +})); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx index 740cad7a1..24e564d01 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenu.tsx @@ -1,17 +1,38 @@ import { memo } from 'react'; -import { Menu, MenuButton, Portal } from '@chakra-ui/react'; +import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5'; +import { Menu, MenuButton, MenuDivider, MenuItem, MenuList, Portal } from '@chakra-ui/react'; +import { isOntimeEvent, SupportedEntry } from 'ontime-types'; + +import { useEntryActions } from '../../../../common/hooks/useEntryAction'; +import { cloneEvent } from '../../../../common/utils/clone'; +import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal'; -import CuesheetTableMenuActionsProps from './CuesheetTableMenuActions'; import { useCuesheetTableMenu } from './useCuesheetTableMenu'; -interface CuesheetTableMenuProps { - showModal: (eventId: string) => void; -} - export default memo(CuesheetTableMenu); -function CuesheetTableMenu({ showModal }: CuesheetTableMenuProps) { +function CuesheetTableMenu() { const { isOpen, eventId, entryIndex, position, closeMenu } = useCuesheetTableMenu(); + const { addEntry, getEntryById, move, deleteEntry } = useEntryActions(); + const showModal = useCuesheetEditModal((state) => state.setEditableEntry); + + const handleCloneEvent = () => { + if (!eventId) { + return; + } + + const currentEvent = getEntryById(eventId); + if (!currentEvent || !isOntimeEvent(currentEvent)) { + return; + } + + const newEvent = cloneEvent(currentEvent); + try { + addEntry(newEvent, { after: eventId }); + } catch (_error) { + // we do not handle errors here + } + }; return ( @@ -26,7 +47,31 @@ function CuesheetTableMenu({ showModal }: CuesheetTableMenuProps) { w={1} h={1} /> - + + } onClick={() => showModal(eventId)}> + Edit ... + + + } onClick={() => addEntry({ type: SupportedEntry.Event }, { before: eventId })}> + Add event above + + } onClick={() => addEntry({ type: SupportedEntry.Event }, { after: eventId })}> + Add event below + + } onClick={handleCloneEvent}> + Clone event + + + } onClick={() => move(eventId, 'up')}> + Move up + + } onClick={() => move(eventId, 'down')}> + Move down + + } onClick={() => deleteEntry([eventId])}> + Delete + + )} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenuActions.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenuActions.tsx deleted file mode 100644 index c24edb12a..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenuActions.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5'; -import { MenuDivider, MenuItem, MenuList } from '@chakra-ui/react'; -import { isOntimeEvent, SupportedEntry } from 'ontime-types'; - -import { useEntryActions } from '../../../../common/hooks/useEntryAction'; -import { cloneEvent } from '../../../../common/utils/clone'; - -interface CuesheetTableMenuActionsProps { - eventId: string; - entryIndex: number; - showModal: (entryId: string) => void; -} - -export default function CuesheetTableMenuActions({ eventId, entryIndex, showModal }: CuesheetTableMenuActionsProps) { - const { addEntry, getEntryById, move, deleteEntry } = useEntryActions(); - - const handleCloneEvent = () => { - const currentEvent = getEntryById(eventId); - if (!currentEvent || !isOntimeEvent(currentEvent)) { - return; - } - - const newEvent = cloneEvent(currentEvent); - try { - addEntry(newEvent, { after: eventId }); - } catch (_error) { - // we do not handle errors here - } - }; - - return ( - - } onClick={() => showModal(eventId)}> - Edit ... - - - } onClick={() => addEntry({ type: SupportedEntry.Event }, { before: eventId })}> - Add event above - - } onClick={() => addEntry({ type: SupportedEntry.Event }, { after: eventId })}> - Add event below - - } onClick={handleCloneEvent}> - Clone event - - - } onClick={() => move(eventId, 'up')}> - Move up - - } onClick={() => move(eventId, 'down')}> - Move down - - } onClick={() => deleteEntry([eventId])}> - Delete - - - ); -} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss index 4ef0c2da7..43f3270f2 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss @@ -1,13 +1,13 @@ .tableSettings { + margin-top: 1rem; grid-area: settings; - padding-inline: 0.5rem; + background-color: $gray-1250; + padding: 0.5rem 1rem; display: flex; - gap: 5rem; + align-items: center; + justify-content: space-between; font-size: $inner-section-text-size; - - @media (max-width: $small-screen) { - gap: 1rem; - } + border-radius: 3px 3px 0 0; } .sectionTitle { @@ -27,3 +27,16 @@ align-items: center; gap: 0.5rem; } + +.column { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-self: start; +} + +.inline { + display: flex; + align-items: center; + gap: 1rem; +} \ No newline at end of file diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx index 157bca0e6..ad5ecab7c 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx @@ -1,10 +1,15 @@ import { memo, ReactNode } from 'react'; -import { Column } from '@tanstack/react-table'; +import { IoChevronDown, IoLocate, IoOptions, IoSettingsOutline } from 'react-icons/io5'; +import { Popover } from '@base-ui-components/react/popover'; +import type { Column } from '@tanstack/react-table'; import { OntimeEntry } from 'ontime-types'; import Button from '../../../../common/components/buttons/Button'; import Checkbox from '../../../../common/components/checkbox/Checkbox'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; +import RotatedLink from '../../../../common/components/icons/RotatedLink'; +import PopoverContents from '../../../../common/components/popover/Popover'; +import { usePersistedCuesheetOptions } from '../../cuesheet.options'; import style from './CuesheetTableSettings.module.scss'; @@ -15,6 +20,7 @@ interface CuesheetTableSettingsProps { handleClearToggles: () => void; } +export default memo(CuesheetTableSettings); function CuesheetTableSettings({ columns, handleResetResizing, @@ -23,9 +29,126 @@ function CuesheetTableSettings({ }: CuesheetTableSettingsProps) { return (
-
- Toggle column visibility -
+
+ + +
+ +
+ + + + + +
+
+ ); +} + +function ViewSettingsFollowButton() { + const followPlayback = usePersistedCuesheetOptions((state) => state.followPlayback); + const toggle = usePersistedCuesheetOptions((state) => state.toggleOption); + + return ( + + ); +} + +function ViewSettings() { + const options = usePersistedCuesheetOptions(); + + return ( + + + Settings + + + } + /> + + + Element visibility + + options.setOption('showActionMenu', checked)} + /> + Show action menu + + + options.setOption('hideTableSeconds', checked)} + /> + Hide seconds in table + + + options.setOption('hidePast', checked)} + /> + Hide past events + + + options.setOption('hideIndexColumn', checked)} + /> + Hide index column + + + Table Behaviour + + options.setOption('showDelayedTimes', checked)} + /> + Show delayed times + + + options.setOption('hideDelays', checked)} + /> + Hide delay entries + + + + ); +} + +function ColumnSettings({ + columns, + handleResetResizing, + handleResetReordering, + handleClearToggles, +}: CuesheetTableSettingsProps) { + return ( + + + View + + + } + /> + +
+ Column visibility {columns.map((column) => { const columnHeader = column.columnDef.header; const visible = column.getIsVisible(); @@ -37,23 +160,20 @@ function CuesheetTableSettings({ ); })}
-
-
- Reset Options -
- - -
-
-
+ + ); } - -export default memo(CuesheetTableSettings); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx b/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx index 28d41e427..ece6adca5 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx @@ -11,7 +11,7 @@ export default function useColumnManager(columns: ColumnDef[]) { }); const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} }); - // if the columns change, we update the dataset + // if the columns order changes, we update the dataset useEffect(() => { let shouldReplace = false; const newColumns: string[] = []; diff --git a/apps/client/src/views/cuesheet/cuesheet.options.ts b/apps/client/src/views/cuesheet/cuesheet.options.ts index d2a42a6ff..daa06ea46 100644 --- a/apps/client/src/views/cuesheet/cuesheet.options.ts +++ b/apps/client/src/views/cuesheet/cuesheet.options.ts @@ -1,110 +1,46 @@ -import { useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; -import { OptionTitle } from '../../common/components/view-params-editor/constants'; -import { ViewOption } from '../../common/components/view-params-editor/viewParams.types'; -import { isStringBoolean } from '../../features/viewers/common/viewUtils'; - -/** - * In the specific case of the cuesheet options - * we save the user preferences in the local storage - */ -export const cuesheetOptions: ViewOption[] = [ - { - title: OptionTitle.ElementVisibility, - collapsible: true, - options: [ - { - id: 'showActionMenu', - title: 'Show action menu', - description: 'Whether to show the action menu for every row in the table', - type: 'boolean', - defaultValue: false, - }, - { - id: 'hideTableSeconds', - title: 'Hide seconds in table', - description: 'Whether to hide seconds in the time fields displayed in the table', - type: 'boolean', - defaultValue: false, - }, - { - id: 'followSelected', - title: 'Follow selected event', - description: 'Whether the view should automatically scroll to the selected event', - type: 'boolean', - defaultValue: false, - }, - { - id: 'hidePast', - title: 'Hide Past Events', - description: 'Whether to hide events that have passed', - type: 'boolean', - defaultValue: false, - }, - { - id: 'hideIndexColumn', - title: 'Hide index column', - description: 'Whether the hide the event indexes in the table', - type: 'boolean', - defaultValue: false, - }, - ], - }, - { - title: OptionTitle.BehaviourOptions, - collapsible: true, - options: [ - { - id: 'showDelayedTimes', - title: 'Show delayed times', - description: 'Whether the time fields should include delays', - type: 'boolean', - defaultValue: false, - }, - { - id: 'hideDelays', - title: 'Hide delays', - description: 'Whether to hide the rows containing scheduled delays', - type: 'boolean', - defaultValue: false, - }, - ], - }, -]; - -type CuesheetOptions = { +type OptionValues = { showActionMenu: boolean; hideTableSeconds: boolean; - followSelected: boolean; + followPlayback: boolean; hidePast: boolean; hideIndexColumn: boolean; showDelayedTimes: boolean; hideDelays: boolean; }; -/** - * Utility extract the view options from URL Params - * the names and fallbacks are manually matched with cuesheetOptions - */ -function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions { - // we manually make an object that matches the key above - return { - showActionMenu: isStringBoolean(searchParams.get('showActionMenu')), - hideTableSeconds: isStringBoolean(searchParams.get('hideTableSeconds')), - followSelected: isStringBoolean(searchParams.get('followSelected')), - hidePast: isStringBoolean(searchParams.get('hidePast')), - hideIndexColumn: isStringBoolean(searchParams.get('hideIndexColumn')), - showDelayedTimes: isStringBoolean(searchParams.get('showDelayedTimes')), - hideDelays: isStringBoolean(searchParams.get('hideDelays')), - }; +const defaultOptions: OptionValues = { + showActionMenu: false, + hideTableSeconds: false, + followPlayback: false, + hidePast: false, + hideIndexColumn: false, + showDelayedTimes: false, + hideDelays: false, +}; + +export type CuesheetOptionKeys = keyof OptionValues; + +export interface CuesheetOptions extends OptionValues { + setOption: (key: K, value: OptionValues[K]) => void; + toggleOption: (key: CuesheetOptionKeys) => void; + resetOptions: () => void; } -/** - * Hook exposes the cuesheet view options - */ -export function useCuesheetOptions(): CuesheetOptions { - const [searchParams] = useSearchParams(); - const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]); - return options; -} +export const usePersistedCuesheetOptions = create()( + persist( + (set) => { + return { + ...defaultOptions, + setOption: (key, value) => set((state) => ({ ...state, [key]: value })), + toggleOption: (key) => set((state) => ({ ...state, [key]: !state[key] })), + resetOptions: () => set(defaultOptions), + }; + }, + { + name: 'cuesheet-options', + }, + ), +); diff --git a/apps/client/src/views/editor/Editor.tsx b/apps/client/src/views/editor/Editor.tsx index 9a9aa220b..f5c7d9831 100644 --- a/apps/client/src/views/editor/Editor.tsx +++ b/apps/client/src/views/editor/Editor.tsx @@ -8,7 +8,7 @@ import { useElectronListener } from '../../common/hooks/useElectronEvent'; import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import AppSettings from '../../features/app-settings/AppSettings'; import useAppSettingsNavigation from '../../features/app-settings/useAppSettingsNavigation'; -import { EditorOverview } from '../../features/overview/Overview'; +import EditorOverview from '../../features/overview/EditorOverview'; import WelcomePlacement from './welcome/WelcomePlacement'; diff --git a/apps/client/src/views/studio/StudioClock.tsx b/apps/client/src/views/studio/StudioClock.tsx index 053a1fe80..78e6bd50b 100644 --- a/apps/client/src/views/studio/StudioClock.tsx +++ b/apps/client/src/views/studio/StudioClock.tsx @@ -1,4 +1,4 @@ -import { useIsMobile } from '../../common/hooks/useIsMobile'; +import { useIsMobileDevice } from '../../common/hooks/useIsMobileDevice'; import { cx } from '../../common/utils/styleUtils'; import { formatTime } from '../../common/utils/time'; import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime'; @@ -17,7 +17,7 @@ interface StudioClockProps { } export default function StudioClock({ onAir, clock, hideCards }: StudioClockProps) { - const isMobile = useIsMobile(); + const isMobile = useIsMobileDevice(); // if we are on mobile and have to show the cards if (isMobile && !hideCards) { diff --git a/apps/server/src/api-data/automation/automation.parser.ts b/apps/server/src/api-data/automation/automation.parser.ts index 9d56217df..39cf0e088 100644 --- a/apps/server/src/api-data/automation/automation.parser.ts +++ b/apps/server/src/api-data/automation/automation.parser.ts @@ -12,6 +12,7 @@ interface LegacyData extends Partial { } export function parseAutomationSettings(data: LegacyData, emitError?: ErrorEmitter): AutomationSettings { + // TODO(v4): move to migration script /** * Leaving a path for migrating users to the new automations * This should be removed after a few releases diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts index f8c7c9aea..6885fcd03 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts @@ -213,6 +213,64 @@ describe('parseRundown()', () => { expect((parsedRundown.entries['2'] as OntimeEvent).custom).toStrictEqual({ sound: 'loud' }); }); + it('removes empty custom fields', () => { + const rundown = { + id: 'test', + title: '', + order: ['1', '2'], + flatOrder: ['1', '2', '21'], + entries: { + '1': makeOntimeEvent({ id: '1', custom: { lighting: 'yes' } }), + '2': makeOntimeBlock({ id: '2', entries: ['21'], custom: { lighting: '' } }), + '21': makeOntimeEvent({ id: '21', custom: { lighting: '' } }), + }, + revision: 1, + } as Rundown; + + const customFields: CustomFields = { + lighting: { + type: 'string', + colour: 'red', + label: 'lighting', + }, + }; + + const parsedRundown = parseRundown(rundown, customFields); + expect((parsedRundown.entries['1'] as OntimeEvent).custom).toStrictEqual({ lighting: 'yes' }); + expect((parsedRundown.entries['2'] as OntimeBlock).custom).not.toHaveProperty('lighting'); + expect((parsedRundown.entries['21'] as OntimeEvent).custom).not.toHaveProperty('lighting'); + }); + + it('parses data in blocks', () => { + const rundown = { + id: 'test', + title: '', + order: ['block'], + flatOrder: ['block'], + isNextDay: false, + entries: { + block: makeOntimeBlock({ + id: 'block', + title: 'block-title', + note: 'block-note', + colour: 'red', + entries: ['1', '2'], + }), + '1': makeOntimeEvent({ id: '1' }), + }, + revision: 1, + } as Rundown; + + const parsedRundown = parseRundown(rundown, {}); + expect(parsedRundown.order.length).toEqual(1); + expect(parsedRundown.entries.block).toMatchObject({ + title: 'block-title', + note: 'block-note', + colour: 'red', + entries: ['1'], + }); + }); + it('parses events nested in blocks', () => { const rundown = { id: 'test', diff --git a/apps/server/src/api-data/rundown/rundown.parser.ts b/apps/server/src/api-data/rundown/rundown.parser.ts index 4eb9837e1..79bf781cd 100644 --- a/apps/server/src/api-data/rundown/rundown.parser.ts +++ b/apps/server/src/api-data/rundown/rundown.parser.ts @@ -19,10 +19,10 @@ import { import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils'; import { defaultRundown } from '../../models/dataModel.js'; -import { delay as delayDef, block as blockDef } from '../../models/eventsDefinition.js'; +import { delay as delayDef } from '../../models/eventsDefinition.js'; import type { ErrorEmitter } from '../../utils/parserUtils.js'; -import { calculateDayOffset, createEvent } from './rundown.utils.js'; +import { calculateDayOffset, cleanupCustomFields, createBlock, createEvent } from './rundown.utils.js'; import { RundownMetadata } from './rundown.types.js'; /** @@ -104,14 +104,7 @@ export function parseRundown( continue; } - // for every field in custom, check that a key exists in customfields - for (const field in newEvent.custom) { - if (!Object.hasOwn(parsedCustomFields, field)) { - emitError?.(`Custom field ${field} not found`); - delete newEvent.custom[field]; - } - } - + cleanupCustomFields(newEvent.custom, parsedCustomFields); eventIndex += 1; } else if (isOntimeDelay(event)) { newEvent = { ...delayDef, duration: event.duration, id }; @@ -128,14 +121,7 @@ export function parseRundown( continue; } - // for every field in custom, check that a key exists in customfields - for (const field in newNestedEvent.custom) { - if (!Object.hasOwn(parsedCustomFields, field)) { - emitError?.(`Custom field ${field} not found`); - delete newNestedEvent.custom[field]; - } - } - + cleanupCustomFields(newNestedEvent.custom, parsedCustomFields); eventIndex += 1; if (newNestedEvent) { @@ -145,16 +131,13 @@ export function parseRundown( } } - newEvent = { - ...blockDef, - title: event.title, - note: event.note, - entries: event.entries?.filter((eventId) => Object.hasOwn(rundown.entries, eventId)) ?? [], - isNextDay: event.isNextDay, - colour: event.colour, - custom: { ...event.custom }, - id, - }; + newEvent = createBlock({ ...structuredClone(event), id }); + // ensure entries exist + if (event.entries?.length > 0) { + newEvent.entries = event.entries.filter((eventId) => Object.hasOwn(rundown.entries, eventId)); + } + // ensure custom fields are valid + cleanupCustomFields(newEvent.custom, parsedCustomFields); } else { emitError?.('Unknown event type, skipping'); continue; diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index 5c5415d66..aae059407 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -1,4 +1,6 @@ import { + CustomFields, + EntryCustomFields, EntryId, isOntimeBlock, isOntimeDelay, @@ -60,7 +62,7 @@ export function generateEvent | Partial): OntimeEvent { +export function createEventPatch(originalEvent: OntimeEvent, patchEvent: Partial): OntimeEvent { if (Object.keys(patchEvent).length === 0) { return originalEvent; } @@ -101,18 +103,52 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial): OntimeBlock { + if (Object.keys(patchBlock).length === 0) { + return originalBlock; + } + + const maybeTargetDuration = () => { + if (typeof patchBlock.targetDuration === 'number') { + return patchBlock.targetDuration; + } + if (patchBlock.targetDuration === null || patchBlock.targetDuration === '') { + return null; + } + return originalBlock.targetDuration; + }; + + return { + id: originalBlock.id, + type: SupportedEntry.Block, + title: makeString(patchBlock.title, originalBlock.title), + note: makeString(patchBlock.note, originalBlock.note), + entries: patchBlock.entries ?? originalBlock.entries, + isNextDay: typeof patchBlock.isNextDay === 'boolean' ? patchBlock.isNextDay : originalBlock.isNextDay, + targetDuration: maybeTargetDuration(), + colour: makeString(patchBlock.colour, originalBlock.colour), + revision: originalBlock.revision, + timeStart: originalBlock.timeStart, + timeEnd: originalBlock.timeEnd, + duration: originalBlock.duration, + isFirstLinked: originalBlock.isFirstLinked, + custom: { ...originalBlock.custom, ...patchBlock.custom }, + }; +} + /** * Utility function for patching an existing event with new data * Increments the revision of the event when applying the patch */ export function applyPatchToEntry(eventFromRundown: T, patch: Partial): T { if (isOntimeEvent(eventFromRundown)) { - const newEvent = createPatch(eventFromRundown, patch as Partial); + const newEvent = createEventPatch(eventFromRundown, patch as Partial); newEvent.revision++; return newEvent as T; } + if (isOntimeBlock(eventFromRundown)) { - const newBlock: OntimeBlock = { ...eventFromRundown, ...patch }; + const newBlock: OntimeBlock = createBlockPatch(eventFromRundown, patch as Partial); newBlock.revision++; return newBlock as T; } @@ -139,7 +175,7 @@ export const createEvent = (eventArgs: Partial, eventIndex: number cue, ...eventDef, }; - const event = createPatch(baseEvent, eventArgs); + const event = createEventPatch(baseEvent, eventArgs); return event; }; @@ -359,6 +395,22 @@ export function getInsertAfterId(rundown: Rundown, afterId?: EntryId, beforeId?: return null; } +/** + * Sanitises custom fields in an entry by removing fields + * - if it does not exist in the project + * - if the value is empty string + * Mutates the entryCustomFields object + */ +export function cleanupCustomFields(entryCustomFields: EntryCustomFields, projectCustomFields: CustomFields) { + for (const field in entryCustomFields) { + if (!Object.hasOwn(projectCustomFields, field)) { + delete entryCustomFields[field]; + } else if (entryCustomFields[field] === '') { + delete entryCustomFields[field]; + } + } +} + /** * converts an index from the timedEventOrder to an index in the playableEventOrder * or returns null if it can not be found diff --git a/apps/server/src/services/rollUtils.ts b/apps/server/src/services/rollUtils.ts index 535e55394..c40a64747 100644 --- a/apps/server/src/services/rollUtils.ts +++ b/apps/server/src/services/rollUtils.ts @@ -69,7 +69,6 @@ export function loadRoll( } // in case we were unable to find anything, we load the first event - console.log('returning first event'); return { event: rundown.entries[firstEventId] as PlayableEvent, index: 0, isPending: true }; } diff --git a/e2e/tests/features/202-cuesheet.spec.ts b/e2e/tests/features/202-cuesheet.spec.ts index 7289286d4..3f5bcb652 100644 --- a/e2e/tests/features/202-cuesheet.spec.ts +++ b/e2e/tests/features/202-cuesheet.spec.ts @@ -3,7 +3,6 @@ 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.getByText('Eurovision Song Contest')).toBeVisible(); await expect(page.getByRole('row', { name: 'Lunch break' })).toBeVisible(); await expect(page.getByRole('row', { name: 'Afternoon break' })).toBeVisible();