From 0f3feca7ab394f4fd41faa955a3005c4a5062e26 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sun, 15 Dec 2024 21:25:41 +0100 Subject: [PATCH 01/12] refactor: extract time-to-end --- apps/client/src/common/hooks/useSocket.ts | 1 + .../src/common/models/TimeManager.type.ts | 1 + apps/client/src/common/utils/eventsManager.ts | 1 + .../interface-panel/EditorSettingsForm.tsx | 1 - .../features/control/message/TimerPreview.tsx | 16 +- .../editor-utils/EditorUtils.module.scss | 2 +- .../src/features/rundown/RundownEntry.tsx | 3 +- .../rundown/event-block/EventBlock.tsx | 5 +- .../rundown/event-block/EventBlockInner.tsx | 21 +- .../event-editor/EventEditor.module.scss | 4 +- .../rundown/event-editor/EventEditor.tsx | 1 + .../composite/EventEditorTimes.tsx | 197 ++++++++++-------- .../composite/EventEditorTitles.tsx | 1 + .../rundown/time-input-flow/TimeInputFlow.tsx | 14 +- .../src/features/viewers/ViewWrapper.tsx | 17 +- .../src/features/viewers/common/viewUtils.ts | 10 +- .../viewers/minimal-timer/MinimalTimer.tsx | 2 +- .../src/features/viewers/timer/Timer.tsx | 2 +- apps/server/src/models/demoProject.ts | 15 +- apps/server/src/models/eventsDefinition.ts | 1 + .../src/services/__tests__/timerUtils.test.ts | 23 +- .../__tests__/rundownCache.test.ts | 12 ++ .../__tests__/sheetUtils.test.ts | 6 + apps/server/src/services/timerUtils.ts | 14 +- .../server/src/utils/__tests__/parser.test.ts | 131 +++++++----- .../utils/__tests__/parserFunctions.test.ts | 104 +++++---- apps/server/src/utils/parser.ts | 19 +- apps/server/src/utils/parserFunctions.ts | 37 +++- e2e/tests/fixtures/test-sheet.xlsx | Bin 6621 -> 12735 bytes .../types/src/definitions/TimerType.type.ts | 1 - .../src/definitions/core/OntimeEvent.type.ts | 1 + .../src/validate-events/validateEvent.test.ts | 4 +- 32 files changed, 419 insertions(+), 248 deletions(-) diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index ee56a8b5a..815b5a392 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -65,6 +65,7 @@ export const useMessagePreview = () => { showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external), showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text), timerType: state.eventNow?.timerType ?? null, + isTimeToEnd: state.eventNow?.isTimeToEnd ?? false, }); return useRuntimeStore(featureSelector); diff --git a/apps/client/src/common/models/TimeManager.type.ts b/apps/client/src/common/models/TimeManager.type.ts index 909dfb68c..46bd672a5 100644 --- a/apps/client/src/common/models/TimeManager.type.ts +++ b/apps/client/src/common/models/TimeManager.type.ts @@ -15,4 +15,5 @@ export type ViewExtendedTimer = { clock: number; timerType: TimerType; + isTimeToEnd: boolean; }; diff --git a/apps/client/src/common/utils/eventsManager.ts b/apps/client/src/common/utils/eventsManager.ts index 485648919..eb0370b26 100644 --- a/apps/client/src/common/utils/eventsManager.ts +++ b/apps/client/src/common/utils/eventsManager.ts @@ -17,6 +17,7 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => { timeEnd: event.timeEnd, timerType: event.timerType, timeStrategy: event.timeStrategy, + isTimeToEnd: event.isTimeToEnd, linkStart: event.linkStart, endAction: event.endAction, isPublic: event.isPublic, diff --git a/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx b/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx index e865ca67e..f6bcd00f6 100644 --- a/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx @@ -88,7 +88,6 @@ export default function EditorSettingsForm() { > - diff --git a/apps/client/src/features/control/message/TimerPreview.tsx b/apps/client/src/features/control/message/TimerPreview.tsx index 4c3765ad5..9eaac95d5 100644 --- a/apps/client/src/features/control/message/TimerPreview.tsx +++ b/apps/client/src/features/control/message/TimerPreview.tsx @@ -16,7 +16,7 @@ import { Corner } from '../../editors/editor-utils/EditorUtils'; import style from './MessageControl.module.scss'; export default function TimerPreview() { - const { blink, blackout, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } = + const { blink, blackout, isTimeToEnd, phase, showAuxTimer, showExternalMessage, showTimerMessage, timerType } = useMessagePreview(); const { data } = useViewSettings(); @@ -24,11 +24,11 @@ export default function TimerPreview() { const main = (() => { if (showTimerMessage) return 'Message'; + if (timerType === TimerType.None) return timerPlaceholder; if (phase === TimerPhase.Pending) return 'Standby to start'; if (phase === TimerPhase.Overtime && data.endMessage) return 'Custom end message'; - if (timerType === TimerType.TimeToEnd) return 'Time to end'; if (timerType === TimerType.Clock) return 'Clock'; - if (timerType === TimerType.None) return timerPlaceholder; + if (isTimeToEnd) return 'Target event scheduled end'; return 'Timer'; })(); @@ -74,12 +74,16 @@ export default function TimerPreview() { - - - + + + ); diff --git a/apps/client/src/features/editors/editor-utils/EditorUtils.module.scss b/apps/client/src/features/editors/editor-utils/EditorUtils.module.scss index b2a4f075a..cb6f1c45a 100644 --- a/apps/client/src/features/editors/editor-utils/EditorUtils.module.scss +++ b/apps/client/src/features/editors/editor-utils/EditorUtils.module.scss @@ -27,7 +27,7 @@ .title { font-size: 1rem; - color: $label-gray; + color: $ui-white; } .label { diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 5df99938c..1166961f1 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -150,6 +150,7 @@ export default function RundownEntry(props: RundownEntryProps) { if (data.type === SupportedEvent.Event) { return ( { +function EventBlockInner(props: EventBlockInnerProps) { const { + eventId, timeStart, timeEnd, duration, timeStrategy, linkStart, - eventId, + isTimeToEnd, isPublic = true, endAction, timerType, @@ -91,7 +93,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => { delay={delay} timeStrategy={timeStrategy} linkStart={linkStart} - timerType={timerType} + isTimeToEnd={isTimeToEnd} />
@@ -122,6 +124,11 @@ const EventBlockInner = (props: EventBlockInnerProps) => { + + + + + @@ -131,7 +138,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
); -}; +} export default memo(EventBlockInner); @@ -162,9 +169,5 @@ function TimerIcon(props: { type: TimerType; className: string }) { if (type === TimerType.None) { return ; } - if (type === TimerType.TimeToEnd) { - const classes = cx([style.active, className]); - return ; - } return ; } diff --git a/apps/client/src/features/rundown/event-editor/EventEditor.module.scss b/apps/client/src/features/rundown/event-editor/EventEditor.module.scss index 99d5b4edc..7f9b8469b 100644 --- a/apps/client/src/features/rundown/event-editor/EventEditor.module.scss +++ b/apps/client/src/features/rundown/event-editor/EventEditor.module.scss @@ -16,7 +16,7 @@ flex: 1; display: flex; flex-direction: column; - gap: 2rem; + gap: 1rem; overflow-y: auto; } @@ -38,6 +38,7 @@ display: flex; flex-direction: column; gap: 1rem; + margin-top: 0.5rem; } .decorated { @@ -63,6 +64,7 @@ gap: 0.5rem; max-width: max-content; cursor: pointer; + height: 30px; // manually match the height of a text input } .inline { diff --git a/apps/client/src/features/rundown/event-editor/EventEditor.tsx b/apps/client/src/features/rundown/event-editor/EventEditor.tsx index 7cb180669..15f8efa3f 100644 --- a/apps/client/src/features/rundown/event-editor/EventEditor.tsx +++ b/apps/client/src/features/rundown/event-editor/EventEditor.tsx @@ -83,6 +83,7 @@ export default function EventEditor() { duration={event.duration} timeStrategy={event.timeStrategy} linkStart={event.linkStart} + isTimeToEnd={event.isTimeToEnd} delay={event.delay ?? 0} isPublic={event.isPublic} endAction={event.endAction} diff --git a/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx b/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx index 671db1883..6a5e6e8d5 100644 --- a/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx +++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx @@ -18,6 +18,7 @@ interface EventEditorTimesProps { duration: number; timeStrategy: TimeStrategy; linkStart: MaybeString; + isTimeToEnd: boolean; delay: number; isPublic: boolean; endAction: EndAction; @@ -26,9 +27,9 @@ interface EventEditorTimesProps { timeDanger: number; } -type HandledActions = 'timerType' | 'endAction' | 'isPublic' | 'timeWarning' | 'timeDanger'; +type HandledActions = 'isTimeToEnd' | 'timerType' | 'endAction' | 'isPublic' | 'timeWarning' | 'timeDanger'; -const EventEditorTimes = (props: EventEditorTimesProps) => { +function EventEditorTimes(props: EventEditorTimesProps) { const { eventId, timeStart, @@ -36,6 +37,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => { duration, timeStrategy, linkStart, + isTimeToEnd, delay, isPublic, endAction, @@ -51,6 +53,11 @@ const EventEditorTimes = (props: EventEditorTimesProps) => { return; } + if (field === 'isTimeToEnd') { + updateEvent({ id: eventId, isTimeToEnd: !(value as boolean) }); + return; + } + if (field === 'timeWarning' || field === 'timeDanger') { const newTime = parseUserTime(value as string); updateEvent({ id: eventId, [field]: newTime }); @@ -71,95 +78,115 @@ const EventEditorTimes = (props: EventEditorTimesProps) => { : ''; return ( -
-
- Event schedule -
- + <> +
+
+ Event schedule +
+ +
+
{delayLabel}
-
{delayLabel}
-
-
-
- Warning Time - -
-
- Timer Type - -
-
- Danger Time - -
-
- End Action - + Event behaviour +
+
+ End Action + +
+
+ Target Event Scheduled End + + handleSubmit('isTimeToEnd', isTimeToEnd)} + variant='ontime' + /> + {isTimeToEnd ? 'On' : 'Off'} + +
+
+ Display options +
+
+ Timer Type + +
+
+ Warning Time + +
-
- Event Visibility - - handleSubmit('isPublic', isPublic)} - variant='ontime' - /> - {isPublic ? 'Public' : 'Private'} - +
+ Event Visibility + + handleSubmit('isPublic', isPublic)} + variant='ontime' + /> + {isPublic ? 'Public' : 'Private'} + +
+
+ Danger Time + +
+
-
+ ); -}; +} export default memo(EventEditorTimes); diff --git a/apps/client/src/features/rundown/event-editor/composite/EventEditorTitles.tsx b/apps/client/src/features/rundown/event-editor/composite/EventEditorTitles.tsx index 02c061238..73dd88764 100644 --- a/apps/client/src/features/rundown/event-editor/composite/EventEditorTitles.tsx +++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorTitles.tsx @@ -29,6 +29,7 @@ const EventEditorTitles = (props: EventEditorTitlesProps) => { return (
+ Event data
Event ID (read only) diff --git a/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx b/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx index b68a43978..f71cb1b27 100644 --- a/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx +++ b/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx @@ -5,7 +5,7 @@ import { IoLink } from '@react-icons/all-files/io5/IoLink'; import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed'; import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline'; import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink'; -import { MaybeString, TimerType, TimeStrategy } from 'ontime-types'; +import { MaybeString, TimeStrategy } from 'ontime-types'; import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton'; import { useEventAction } from '../../../common/hooks/useEventAction'; @@ -16,19 +16,19 @@ import style from './TimeInputFlow.module.scss'; interface EventBlockTimerProps { eventId: string; + isTimeToEnd: boolean; timeStart: number; timeEnd: number; duration: number; timeStrategy: TimeStrategy; linkStart: MaybeString; delay: number; - timerType: TimerType; } type TimeActions = 'timeStart' | 'timeEnd' | 'duration'; -const TimeInputFlow = (props: EventBlockTimerProps) => { - const { eventId, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, timerType } = props; +function TimeInputFlow(props: EventBlockTimerProps) { + const { eventId, isTimeToEnd, timeStart, timeEnd, duration, timeStrategy, linkStart, delay } = props; const { updateEvent, updateTimer } = useEventAction(); // In sync with EventEditorTimes @@ -49,8 +49,8 @@ const TimeInputFlow = (props: EventBlockTimerProps) => { warnings.push('Over midnight'); } - if (timerType === TimerType.TimeToEnd) { - warnings.push('Time to end'); + if (isTimeToEnd) { + warnings.push('Target event scheduled end'); } const hasDelay = delay !== 0; @@ -128,6 +128,6 @@ const TimeInputFlow = (props: EventBlockTimerProps) => { )} ); -}; +} export default memo(TimeInputFlow); diff --git a/apps/client/src/features/viewers/ViewWrapper.tsx b/apps/client/src/features/viewers/ViewWrapper.tsx index a0b78bd9b..eb6819e20 100644 --- a/apps/client/src/features/viewers/ViewWrapper.tsx +++ b/apps/client/src/features/viewers/ViewWrapper.tsx @@ -9,6 +9,7 @@ import { Settings, SimpleTimerState, SupportedEvent, + TimerType, ViewSettings, } from 'ontime-types'; import { useStore } from 'zustand'; @@ -74,16 +75,14 @@ const withData =

(Component: ComponentType

) => { const selectedId = eventNow?.id ?? null; const nextId = eventNext?.id ?? null; - /******************************************/ - /*** + TimeManagerType ***/ - /*** WRAP INFORMATION RELATED TO TIME ***/ - /*** -------------------------------- ***/ - /******************************************/ - - const TimeManagerType = { + /** + * Contains an extended timer object with properties from the current event + */ + const timeManagerType: ViewExtendedTimer = { ...timer, clock, - timerType: eventNow?.timerType ?? null, + timerType: eventNow?.timerType ?? TimerType.CountDown, + isTimeToEnd: eventNow?.isTimeToEnd ?? false, }; return ( @@ -108,7 +107,7 @@ const withData =

(Component: ComponentType

) => { runtime={runtime} selectedId={selectedId} settings={settings} - time={TimeManagerType} + time={timeManagerType} viewSettings={viewSettings} /> diff --git a/apps/client/src/features/viewers/common/viewUtils.ts b/apps/client/src/features/viewers/common/viewUtils.ts index bfc2e3046..88bcc41c4 100644 --- a/apps/client/src/features/viewers/common/viewUtils.ts +++ b/apps/client/src/features/viewers/common/viewUtils.ts @@ -5,16 +5,22 @@ import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type' import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils'; import { formatTime } from '../../../common/utils/time'; -type TimerTypeParams = Pick; +type TimerTypeParams = Pick; export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams): number | null { if (!timerObject) { return null; } + if (timerObject.isTimeToEnd) { + if (timerObject.current === null) { + return null; + } + return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current; + } + switch (timerObject.timerType) { case TimerType.CountDown: - case TimerType.TimeToEnd: if (timerObject.current === null) { return null; } diff --git a/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx b/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx index 633b0aee7..83cc0f6d7 100644 --- a/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx +++ b/apps/client/src/features/viewers/minimal-timer/MinimalTimer.tsx @@ -124,7 +124,7 @@ export default function MinimalTimer(props: MinimalTimerProps) { const isPlaying = time.playback !== Playback.Pause; - const shouldShowModifiers = time.timerType === TimerType.CountDown || time.timerType === TimerType.TimeToEnd; + const shouldShowModifiers = time.timerType === TimerType.CountDown || time.isTimeToEnd; const finished = time.phase === TimerPhase.Overtime; const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage && !hideEndMessage; const showFinished = diff --git a/apps/client/src/features/viewers/timer/Timer.tsx b/apps/client/src/features/viewers/timer/Timer.tsx index d0b82cd22..a32052cb3 100644 --- a/apps/client/src/features/viewers/timer/Timer.tsx +++ b/apps/client/src/features/viewers/timer/Timer.tsx @@ -118,7 +118,7 @@ export default function Timer(props: TimerProps) { const finished = time.phase === TimerPhase.Overtime; const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0); - const shouldShowModifiers = time.timerType === TimerType.CountDown || time.timerType === TimerType.TimeToEnd; + const shouldShowModifiers = time.timerType === TimerType.CountDown || time.isTimeToEnd; const showEndMessage = shouldShowModifiers && finished && viewSettings.endMessage; const showProgress = eventNow !== null && diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index 78ed0cef9..91e3abdcc 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -10,6 +10,7 @@ export const demoDb: DatabaseModel = { note: 'SF1.01', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, timeStart: 36000000, @@ -34,6 +35,7 @@ export const demoDb: DatabaseModel = { note: 'SF1.02', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, timeStart: 37500000, @@ -58,6 +60,7 @@ export const demoDb: DatabaseModel = { note: 'SF1.03', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, timeStart: 39000000, @@ -82,6 +85,7 @@ export const demoDb: DatabaseModel = { note: 'SF1.04', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, timeStart: 40500000, @@ -106,6 +110,7 @@ export const demoDb: DatabaseModel = { note: 'SF1.05', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, timeStart: 42000000, @@ -135,6 +140,7 @@ export const demoDb: DatabaseModel = { note: 'SF1.06', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, timeStart: 47100000, @@ -159,6 +165,7 @@ export const demoDb: DatabaseModel = { note: 'SF1.07', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, timeStart: 48600000, @@ -183,6 +190,7 @@ export const demoDb: DatabaseModel = { note: 'SF1.08', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, timeStart: 50100000, @@ -207,6 +215,7 @@ export const demoDb: DatabaseModel = { note: 'SF1.09', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, timeStart: 51600000, @@ -231,6 +240,7 @@ export const demoDb: DatabaseModel = { note: 'SF1.10', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, timeStart: 53100000, @@ -260,6 +270,7 @@ export const demoDb: DatabaseModel = { note: 'SF1.11', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, timeStart: 56100000, @@ -284,9 +295,9 @@ export const demoDb: DatabaseModel = { note: 'SF1.12', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, - timeStart: 57600000, timeEnd: 58800000, duration: 1200000, @@ -309,6 +320,7 @@ export const demoDb: DatabaseModel = { note: 'SF1.13', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, timeStart: 59100000, @@ -333,6 +345,7 @@ export const demoDb: DatabaseModel = { note: 'SF1.14', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, linkStart: null, timeStrategy: TimeStrategy.LockEnd, timeStart: 60600000, diff --git a/apps/server/src/models/eventsDefinition.ts b/apps/server/src/models/eventsDefinition.ts index 4760d7c3c..cc8bc563c 100644 --- a/apps/server/src/models/eventsDefinition.ts +++ b/apps/server/src/models/eventsDefinition.ts @@ -15,6 +15,7 @@ export const event: Omit = { timerType: TimerType.CountDown, timeStrategy: TimeStrategy.LockDuration, linkStart: null, + isTimeToEnd: false, timeStart: 0, timeEnd: 0, duration: 0, diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index 2f9f418ae..01fd0276c 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -175,7 +175,7 @@ describe('getExpectedFinish()', () => { const state = { eventNow: { timeEnd: 30, - timerType: TimerType.TimeToEnd, + isTimeToEnd: true, }, timer: { addedTime: 10, @@ -195,7 +195,7 @@ describe('getExpectedFinish()', () => { const state = { eventNow: { timeEnd: 600000, // 00:10:00 - timerType: TimerType.TimeToEnd, + isTimeToEnd: true, }, timer: { addedTime: 0, @@ -351,7 +351,7 @@ describe('getCurrent()', () => { const state = { eventNow: { timeEnd: 100, - timerType: TimerType.TimeToEnd, + isTimeToEnd: true, }, clock: 30, timer: { @@ -376,7 +376,7 @@ describe('getCurrent()', () => { const state = { eventNow: { timeEnd: 100, - timerType: TimerType.TimeToEnd, + isTimeToEnd: true, }, clock: 30, timer: { @@ -401,7 +401,7 @@ describe('getCurrent()', () => { const state = { eventNow: { timeEnd: 100, - timerType: TimerType.TimeToEnd, + isTimeToEnd: true, }, clock: 30, timer: { @@ -427,7 +427,7 @@ describe('getCurrent()', () => { eventNow: { timeStart: 79200000, // 22:00:00 timeEnd: 600000, // 00:10:00 - timerType: TimerType.TimeToEnd, + isTimeToEnd: true, }, clock: 79500000, // 22:05:00 timer: { @@ -456,7 +456,7 @@ describe('getCurrent()', () => { timeStart: 77400000, // 21:30:00 timeEnd: 81000000, // 22:30:00 duration: 3600000, // 01:00:00 - timerType: TimerType.TimeToEnd, + isTimeToEnd: true, }, timer: { addedTime: 0, @@ -909,7 +909,8 @@ describe('getRuntimeOffset()', () => { timeStrategy: TimeStrategy.LockEnd, linkStart: null, endAction: EndAction.None, - timerType: TimerType.TimeToEnd, + timerType: TimerType.CountDown, + isTimeToEnd: true, isPublic: true, skip: false, note: '', @@ -961,7 +962,8 @@ describe('getRuntimeOffset()', () => { timeStrategy: TimeStrategy.LockEnd, linkStart: null, endAction: EndAction.None, - timerType: TimerType.TimeToEnd, + timerType: TimerType.CountDown, + isTimeToEnd: true, isPublic: true, skip: false, note: '', @@ -1011,7 +1013,8 @@ describe('getRuntimeOffset()', () => { timeStrategy: TimeStrategy.LockEnd, linkStart: null, endAction: EndAction.None, - timerType: TimerType.TimeToEnd, // <--- but this is time to end + timerType: TimerType.CountDown, + isTimeToEnd: true, }, runtime: { selectedEventIndex: 0, diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index c6af0745b..5eccaeacc 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -565,6 +565,7 @@ describe('calculateRuntimeDelays', () => { note: '', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, timeStrategy: TimeStrategy.LockEnd, linkStart: null, timeStart: 600000, @@ -591,6 +592,7 @@ describe('calculateRuntimeDelays', () => { note: '', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, timeStrategy: TimeStrategy.LockEnd, linkStart: null, timeStart: 1200000, @@ -617,6 +619,7 @@ describe('calculateRuntimeDelays', () => { note: '', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, timeStrategy: TimeStrategy.LockEnd, linkStart: null, timeStart: 600000, @@ -643,6 +646,7 @@ describe('calculateRuntimeDelays', () => { note: '', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, timeStrategy: TimeStrategy.LockEnd, linkStart: null, timeStart: 1200000, @@ -678,6 +682,7 @@ describe('getDelayAt()', () => { note: '', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, timeStrategy: TimeStrategy.LockEnd, linkStart: null, timeStart: 600000, @@ -705,6 +710,7 @@ describe('getDelayAt()', () => { note: '', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, timeStrategy: TimeStrategy.LockEnd, linkStart: null, timeStart: 1200000, @@ -732,6 +738,7 @@ describe('getDelayAt()', () => { note: '', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, timeStrategy: TimeStrategy.LockEnd, linkStart: null, timeStart: 600000, @@ -759,6 +766,7 @@ describe('getDelayAt()', () => { note: '', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, timeStrategy: TimeStrategy.LockEnd, linkStart: null, timeStart: 1200000, @@ -812,6 +820,7 @@ describe('calculateRuntimeDelaysFrom()', () => { note: '', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, timeStrategy: TimeStrategy.LockEnd, linkStart: null, timeStart: 600000, @@ -839,6 +848,7 @@ describe('calculateRuntimeDelaysFrom()', () => { note: '', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, timeStrategy: TimeStrategy.LockEnd, linkStart: null, timeStart: 1200000, @@ -866,6 +876,7 @@ describe('calculateRuntimeDelaysFrom()', () => { note: '', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, timeStrategy: TimeStrategy.LockEnd, linkStart: null, timeStart: 600000, @@ -893,6 +904,7 @@ describe('calculateRuntimeDelaysFrom()', () => { note: '', endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, timeStrategy: TimeStrategy.LockEnd, linkStart: null, timeStart: 1200000, diff --git a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts index 3304c74c1..c4fb5c6b0 100644 --- a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts +++ b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts @@ -31,6 +31,7 @@ describe('cellRequestFromEvent()', () => { linkStart: null, endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, duration: 10800000, isPublic: false, skip: false, @@ -73,6 +74,7 @@ describe('cellRequestFromEvent()', () => { timeEnd: 57600000, endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, duration: 10800000, timeStrategy: TimeStrategy.LockEnd, linkStart: null, @@ -119,6 +121,7 @@ describe('cellRequestFromEvent()', () => { timeEnd: 57600000, endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, duration: 10800000, timeStrategy: TimeStrategy.LockEnd, linkStart: null, @@ -164,6 +167,7 @@ describe('cellRequestFromEvent()', () => { timeEnd: 57600000, endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, timeStrategy: TimeStrategy.LockEnd, linkStart: null, duration: 10800000, @@ -195,6 +199,7 @@ describe('cellRequestFromEvent()', () => { timeEnd: 57600000, endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, duration: 10800000, timeStrategy: TimeStrategy.LockEnd, linkStart: null, @@ -227,6 +232,7 @@ describe('cellRequestFromEvent()', () => { timeEnd: 57600000, endAction: EndAction.None, timerType: TimerType.CountDown, + isTimeToEnd: false, duration: 10800000, timeStrategy: TimeStrategy.LockEnd, linkStart: null, diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 28fd41263..df7d3f7c2 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,4 +1,4 @@ -import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types'; +import { MaybeNumber, Playback, TimerPhase } from 'ontime-types'; import { dayInMs } from 'ontime-utils'; import { RuntimeState } from '../stores/runtimeState.js'; @@ -19,7 +19,7 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber { return null; } - const { timerType, timeEnd } = state.eventNow; + const { isTimeToEnd, timeEnd } = state.eventNow; const { pausedAt } = state._timer; const { clock } = state; @@ -33,7 +33,7 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber { const pausedTime = pausedAt != null ? clock - pausedAt : 0; - if (timerType === TimerType.TimeToEnd) { + if (isTimeToEnd) { return timeEnd + addedTime + pausedTime; } @@ -62,11 +62,11 @@ export function getCurrent(state: RuntimeState): number { } } const { startedAt, duration, addedTime } = state.timer; - const { timerType, timeStart, timeEnd } = state.eventNow; + const { isTimeToEnd, timeStart, timeEnd } = state.eventNow; const { pausedAt } = state._timer; const { clock } = state; - if (timerType === TimerType.TimeToEnd) { + if (isTimeToEnd) { const isEventOverMidnight = timeStart > timeEnd; const correctDay = isEventOverMidnight ? dayInMs : 0; return correctDay - clock + timeEnd + addedTime; @@ -131,7 +131,7 @@ export function getRuntimeOffset(state: RuntimeState): number { } const { clock } = state; - const { timeStart, timerType } = state.eventNow; + const { isTimeToEnd, timeStart } = state.eventNow; const { addedTime, current, startedAt } = state.timer; // if we havent started, but the timer is armed @@ -142,7 +142,7 @@ export function getRuntimeOffset(state: RuntimeState): number { const overtime = Math.min(current, 0); // in time-to-end, offset is overtime - if (timerType === TimerType.TimeToEnd) { + if (isTimeToEnd) { return overtime; } diff --git a/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index dc9d765e0..f8b32b1f3 100644 --- a/apps/server/src/utils/__tests__/parser.test.ts +++ b/apps/server/src/utils/__tests__/parser.test.ts @@ -463,7 +463,7 @@ describe('test event validator', () => { const event = { title: 'test', }; - const validated = createEvent(event, 'test'); + const validated = createEvent(event, 1); expect(validated).toEqual( expect.objectContaining({ @@ -471,12 +471,13 @@ describe('test event validator', () => { note: expect.any(String), timeStart: expect.any(Number), timeEnd: expect.any(Number), + isTimeToEnd: expect.any(Boolean), isPublic: expect.any(Boolean), skip: expect.any(Boolean), revision: expect.any(Number), type: expect.any(String), id: expect.any(String), - cue: 'test', + cue: '2', colour: expect.any(String), custom: expect.any(Object), }), @@ -485,7 +486,7 @@ describe('test event validator', () => { it('fails an empty object', () => { const event = {}; - const validated = createEvent(event, 'none'); + const validated = createEvent(event, 1); expect(validated).toEqual(null); }); @@ -495,7 +496,7 @@ describe('test event validator', () => { note: '1899-12-30T08:00:10.000Z', }; // @ts-expect-error -- we know this is wrong, testing imports outside domain - const validated = createEvent(event, 'not-used'); + const validated = createEvent(event, 1); if (validated === null) { throw new Error('unexpected value'); } @@ -1659,7 +1660,7 @@ describe('parseExcel()', () => { expect((events.at(0) as OntimeEvent).colour).toEqual('#F00'); //<--trailing white space in Excel data }); - it('link start', () => { + it('parses link start and checks that is applicable', () => { const testData = [ [ 'Time Start', @@ -1675,11 +1676,11 @@ describe('parseExcel()', () => { 'Timer type', ], ['4:30:00', '9:45:00', 'A', 'load-next', '', '', 'Rainbow chase', '#F00', 102, '', 'count-down'], - ['9:45:00', '10:56:00', 'C', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], - ['10:00:00', '16:36:00', 'D', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102, 'x', 'count-down'], //<-- incorrect start times are overridden - ['21:45:00', '22:56:00', 'E', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, '', 'count-down'], + ['9:45:00', '10:56:00', 'B', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], + ['10:00:00', '16:36:00', 'C', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102, 'x', 'count-down'], // <-- incorrect start times are overridden + ['21:45:00', '22:56:00', 'D', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, '', 'count-down'], ['', '', 'BLOCK', '', '', '', '', '', '', '', 'block'], - ['00:0:00', '23:56:00', 'G', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], //<-- link past blocks + ['00:0:00', '23:56:00', 'E', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], // <-- link past blocks [], ]; @@ -1709,30 +1710,46 @@ describe('parseExcel()', () => { const { rundown, order } = cache.get(); const firstId = order.at(0); // A - const secondId = order.at(1); // C - const thirdId = order.at(2); // D - const fourthId = order.at(3); // E - const fifhtId = order.at(4); // Block - const sixthId = order.at(5); // G + const secondId = order.at(1); // B + const thirdId = order.at(2); // C + const fourthId = order.at(3); // D + const fifthId = order.at(4); // Block + const sixthId = order.at(5); // E - if (!firstId || !secondId || !thirdId || !fourthId || !fifhtId || !sixthId) { + if (!firstId || !secondId || !thirdId || !fourthId || !fifthId || !sixthId) { throw new Error('Unexpected value'); } - expect((rundown[firstId] as OntimeEvent).timeStart).toEqual(16200000); - - expect((rundown[secondId] as OntimeEvent).timeStart).toEqual((rundown[firstId] as OntimeEvent).timeEnd); - expect((rundown[secondId] as OntimeEvent).linkStart).toEqual((rundown[firstId] as OntimeEvent).id); - - expect((rundown[thirdId] as OntimeEvent).timeStart).toEqual((rundown[secondId] as OntimeEvent).timeEnd); - expect((rundown[thirdId] as OntimeEvent).linkStart).toEqual((rundown[secondId] as OntimeEvent).id); - - expect((rundown[fourthId] as OntimeEvent).timeStart).toEqual(78300000); - - expect((rundown[fifhtId] as OntimeEvent).type).toEqual(SupportedEvent.Block); - - expect((rundown[sixthId] as OntimeEvent).timeStart).toEqual((rundown[fourthId] as OntimeEvent).timeEnd); - expect((rundown[sixthId] as OntimeEvent).linkStart).toEqual((rundown[fourthId] as OntimeEvent).id); + expect(rundown).toMatchObject({ + [firstId]: { + title: 'A', + timeStart: 16200000, + }, + [secondId]: { + title: 'B', + timeStart: (rundown[firstId] as OntimeEvent).timeEnd, + linkStart: (rundown[firstId] as OntimeEvent).id, + }, + [thirdId]: { + title: 'C', + timeStart: (rundown[secondId] as OntimeEvent).timeEnd, + linkStart: (rundown[secondId] as OntimeEvent).id, + }, + [fourthId]: { + title: 'D', + timeStart: 78300000, + linkStart: null, + }, + [fifthId]: { + title: 'BLOCK', + type: SupportedEvent.Block, + }, + [sixthId]: { + title: 'E', + timeStart: (rundown[fourthId] as OntimeEvent).timeEnd, + linkStart: (rundown[fourthId] as OntimeEvent).id, + }, + }); }); it('#971 BUG: parses time fields and booleans', () => { @@ -1762,7 +1779,7 @@ describe('parseExcel()', () => { 'false', 'Setup', '', - 'time-to-end', + 'count-down', 'none', '15', '00:05:00', @@ -1778,7 +1795,7 @@ describe('parseExcel()', () => { 'false', 'Meeting 1', '', - 'time-to-end', + 'count-down', 'none', 15, '00:05:00', @@ -1794,7 +1811,7 @@ describe('parseExcel()', () => { 'false', 'Meeting 2', '', - 'time-to-end', + 'count-down', 'none', '13', '5', @@ -1810,7 +1827,7 @@ describe('parseExcel()', () => { 'true', 'Lunch', '', - 'time-to-end', + 'count-down', 'none', 13, 5, @@ -1839,26 +1856,46 @@ describe('parseExcel()', () => { const parsedData = parseExcel(testData, {}); const { rundown } = parsedData; + // '15' as a string is parsed by smart time entry as minutes + expect(rundown[0]).toMatchObject({ + cue: 'SETUP', + timeWarning: 15 * MILLIS_PER_MINUTE, + }); + // elements in bug report // 15 is a number, in which case we parse it as a minutes value - expect((rundown.at(1) as OntimeEvent).timeWarning).toBe(15 * MILLIS_PER_MINUTE); + expect(rundown[1]).toMatchObject({ + cue: 'MEET1', + timeWarning: 15 * MILLIS_PER_MINUTE, + }); // in the case where a string is passed, we need to check whether it is an ISO 8601 date - expect((rundown.at(2) as OntimeEvent).duration).toBe(60 * MILLIS_PER_MINUTE); - expect((rundown.at(2) as OntimeEvent).timeDanger).toBe(5 * MILLIS_PER_MINUTE); + expect(rundown[2]).toMatchObject({ + cue: 'MEET2', + duration: 60 * MILLIS_PER_MINUTE, + timeDanger: 5 * MILLIS_PER_MINUTE, + }); - expect((rundown.at(3) as OntimeEvent).timeWarning).toBe(13 * MILLIS_PER_MINUTE); - expect((rundown.at(3) as OntimeEvent).timeDanger).toBe(5 * MILLIS_PER_MINUTE); + expect(rundown[3]).toMatchObject({ + cue: 'lunch', + timeWarning: 13 * MILLIS_PER_MINUTE, + timeDanger: 5 * MILLIS_PER_MINUTE, + }); - expect((rundown.at(4) as OntimeEvent).duration).toBe(90 * MILLIS_PER_MINUTE); - expect((rundown.at(4) as OntimeEvent).linkStart).toBe(false); - expect((rundown.at(4) as OntimeEvent).timeWarning).toBe(11 * MILLIS_PER_MINUTE); - expect((rundown.at(4) as OntimeEvent).timeDanger).toBe(5 * MILLIS_PER_MINUTE); + expect(rundown[4]).toMatchObject({ + cue: 'MEET3', + duration: 90 * MILLIS_PER_MINUTE, + linkStart: false, + timeWarning: 11 * MILLIS_PER_MINUTE, + timeDanger: 5 * MILLIS_PER_MINUTE, + }); - expect((rundown.at(5) as OntimeEvent).duration).toBe(30 * MILLIS_PER_MINUTE); - - // if we get a boolean, we should just use that - expect((rundown.at(5) as OntimeEvent).linkStart).toBe(true); - expect((rundown.at(5) as OntimeEvent).timeWarning).toBe(11 * MILLIS_PER_MINUTE); + expect(rundown[5]).toMatchObject({ + cue: 'MEET4', + duration: 30 * MILLIS_PER_MINUTE, + timeWarning: 11 * MILLIS_PER_MINUTE, + // if we get a boolean, we should just use that + linkStart: true, + }); }); }); diff --git a/apps/server/src/utils/__tests__/parserFunctions.test.ts b/apps/server/src/utils/__tests__/parserFunctions.test.ts index ad0de0937..7a6e03824 100644 --- a/apps/server/src/utils/__tests__/parserFunctions.test.ts +++ b/apps/server/src/utils/__tests__/parserFunctions.test.ts @@ -405,28 +405,6 @@ describe('sanitiseCustomFields()', () => { }); describe('parseRundown() linking', () => { - const blankEvent: OntimeEvent = { - id: '', - type: SupportedEvent.Event, - cue: '', - title: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - linkStart: null, - timeStrategy: TimeStrategy.LockDuration, - timeStart: 0, - timeEnd: 0, - duration: 0, - isPublic: false, - skip: false, - colour: '', - revision: 0, - timeWarning: 120000, - timeDanger: 60000, - custom: {}, - }; - it('returns linked events', () => { const data: Partial = { rundown: [ @@ -445,12 +423,11 @@ describe('parseRundown() linking', () => { customFields: {}, }; - const expected: OntimeRundown = [ - { ...blankEvent, id: '1', cue: '0' }, - { ...blankEvent, id: '2', cue: '1', linkStart: '1' }, - ]; const result = parseRundown(data); - expect(result.rundown).toEqual(expected); + expect(result.rundown[1]).toMatchObject({ + id: '2', + linkStart: '1', + }); }); it('returns unlinked if no previous', () => { @@ -466,9 +443,11 @@ describe('parseRundown() linking', () => { customFields: {}, }; - const expected: OntimeRundown = [{ ...blankEvent, id: '2', cue: '0' }]; const result = parseRundown(data); - expect(result.rundown).toEqual(expected); + expect(result.rundown[0]).toMatchObject({ + id: '2', + linkStart: null, + }); }); it('returns linked events past blocks and delays', () => { @@ -505,14 +484,65 @@ describe('parseRundown() linking', () => { customFields: {}, }; - const expected: OntimeRundown = [ - { ...blankEvent, id: '1', cue: '0' }, - { id: 'delay1', type: SupportedEvent.Delay, duration: 0 }, - { ...blankEvent, id: '2', cue: '1', linkStart: '1' }, - { id: 'block1', type: SupportedEvent.Block, title: '' }, - { ...blankEvent, id: '3', cue: '2', linkStart: '2' }, - ]; const result = parseRundown(data); - expect(result.rundown).toEqual(expected); + expect(result.rundown[0]).toMatchObject({ + id: '1', + cue: '1', + }); + // skip delay + expect(result.rundown[2]).toMatchObject({ + id: '2', + cue: '2', + linkStart: '1', + }); + // skip block + expect(result.rundown[4]).toMatchObject({ + id: '3', + cue: '3', + linkStart: '2', + }); + }); +}); + +describe('parseRundown() migrations', () => { + const legacyEvent = { + id: '1', + type: SupportedEvent.Event, + cue: '', + title: '', + note: '', + endAction: EndAction.None, + timerType: 'time-to-end', + linkStart: null, + timeStrategy: TimeStrategy.LockDuration, + timeStart: 0, + timeEnd: 0, + duration: 0, + isPublic: false, + skip: false, + colour: '', + revision: 0, + timeWarning: 120000, + timeDanger: 60000, + custom: {}, + }; + + it('migrates an event with time-to-end', () => { + const result = parseRundown({ rundown: [legacyEvent] as OntimeRundown }); + expect(result.rundown[0]).toMatchObject({ + id: '1', + timerType: TimerType.CountDown, + isTimeToEnd: true, + }); + }); + + it('migrates an event without time-to-end', () => { + const countdownEvent = { ...legacyEvent, timerType: TimerType.CountDown }; + const result = parseRundown({ rundown: [countdownEvent] as OntimeRundown }); + expect(result.rundown[0]).toMatchObject({ + id: '1', + timerType: TimerType.CountDown, + isTimeToEnd: false, + }); }); }); diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index db410bfb6..e9822f984 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -13,8 +13,8 @@ import { CustomFields, DatabaseModel, EventCustomFields, + isOntimeBlock, LogOrigin, - OntimeBlock, OntimeEvent, OntimeRundown, SupportedEvent, @@ -271,10 +271,11 @@ export const parseExcel = ( // if any data was found in row, push to array const keysFound = Object.keys(event).length + Object.keys(eventCustomFields).length; + console.log('keys found ---->', event) if (keysFound > 0) { // if it is a Block type drop all other filed - if (event.type === SupportedEvent.Block) { - rundown.push({ type: event.type, id: event.id, title: event.title } as OntimeBlock); + if (isOntimeBlock(event)) { + rundown.push({ type: event.type, id: event.id, title: event.title }); } else { if (timerTypeIndex === null) { event.timerType = TimerType.CountDown; @@ -360,7 +361,6 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial, cueFallback: string): OntimeEvent | null => { +export const createEvent = (eventArgs: Partial, eventIndex: number | string): OntimeEvent | null => { if (Object.keys(eventArgs).length === 0) { return null; } + const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex; + const baseEvent = { id: eventArgs?.id ?? generateId(), - cue: cueFallback, + cue, ...eventDef, }; const event = createPatch(baseEvent, eventArgs); diff --git a/apps/server/src/utils/parserFunctions.ts b/apps/server/src/utils/parserFunctions.ts index dadde53bb..1889ac250 100644 --- a/apps/server/src/utils/parserFunctions.ts +++ b/apps/server/src/utils/parserFunctions.ts @@ -12,6 +12,7 @@ import { OscSubscription, ProjectData, Settings, + TimerType, URLPreset, ViewSettings, isOntimeBlock, @@ -19,13 +20,7 @@ import { isOntimeDelay, isOntimeEvent, } from 'ontime-types'; -import { - customFieldLabelToKey, - generateId, - getErrorMessage, - getLastEvent, - isAlphanumericWithSpace, -} from 'ontime-utils'; +import { customFieldLabelToKey, generateId, getErrorMessage, isAlphanumericWithSpace } from 'ontime-utils'; import { dbModel } from '../models/dataModel.js'; import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js'; @@ -52,6 +47,7 @@ export function parseRundown( const rundown: OntimeRundown = []; let eventIndex = 0; + let previousId: string | null = null; const ids: string[] = []; for (const event of data.rundown) { @@ -64,12 +60,13 @@ export function parseRundown( let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null; if (isOntimeEvent(event)) { + const maybeEvent = runEventMigrations({ ...event, id }); + if (event.linkStart) { - const prevId = getLastEvent(rundown).lastEvent?.id ?? null; - event.linkStart = prevId; + maybeEvent.linkStart = previousId; } - newEvent = createEvent(event, eventIndex.toString()); + newEvent = createEvent(maybeEvent, eventIndex); // skip if event is invalid if (newEvent == null) { emitError?.('Skipping event without payload'); @@ -84,6 +81,7 @@ export function parseRundown( } } + previousId = id; eventIndex += 1; } else if (isOntimeDelay(event)) { newEvent = { ...delayDef, duration: event.duration, id }; @@ -349,3 +347,22 @@ export function sanitiseCustomFields(data: object): CustomFields { return newCustomFields; } + +/** + * Time to end was moved from a TimerType to a standalone boolean + * Released as part of v3.10.0 + */ +function migrateTimeToEnd(event: any): OntimeEvent { + if (event.timerType === 'time-to-end') { + event.timerType = TimerType.CountDown; + event.isTimeToEnd = true; + } + return event; +} + +/** + * Mutating function migrates event data entries + */ +function runEventMigrations(event: any): OntimeEvent { + return migrateTimeToEnd(event); +} diff --git a/e2e/tests/fixtures/test-sheet.xlsx b/e2e/tests/fixtures/test-sheet.xlsx index aa335bea13947f2caca605f2bb0e5578e28908f5..a5f2a8f96911b0c05bd5ac528d8c263c9d640d1c 100644 GIT binary patch literal 12735 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 G_Wu9{1X(Kp diff --git a/packages/types/src/definitions/TimerType.type.ts b/packages/types/src/definitions/TimerType.type.ts index e9bae59fb..df9e0757d 100644 --- a/packages/types/src/definitions/TimerType.type.ts +++ b/packages/types/src/definitions/TimerType.type.ts @@ -1,7 +1,6 @@ export enum TimerType { CountDown = 'count-down', CountUp = 'count-up', - TimeToEnd = 'time-to-end', Clock = 'clock', None = 'none', } diff --git a/packages/types/src/definitions/core/OntimeEvent.type.ts b/packages/types/src/definitions/core/OntimeEvent.type.ts index ac9d08a25..6cef9896c 100644 --- a/packages/types/src/definitions/core/OntimeEvent.type.ts +++ b/packages/types/src/definitions/core/OntimeEvent.type.ts @@ -29,6 +29,7 @@ export type OntimeEvent = OntimeBaseEvent & { note: string; endAction: EndAction; timerType: TimerType; + isTimeToEnd: boolean; linkStart: MaybeString; // ID of event to link to timeStrategy: TimeStrategy; timeStart: number; diff --git a/packages/utils/src/validate-events/validateEvent.test.ts b/packages/utils/src/validate-events/validateEvent.test.ts index c7fa207fb..7ccd77153 100644 --- a/packages/utils/src/validate-events/validateEvent.test.ts +++ b/packages/utils/src/validate-events/validateEvent.test.ts @@ -18,8 +18,8 @@ describe('validateEndAction()', () => { describe('validateTimerType()', () => { it('recognises a string representation of an action', () => { - const timerType = validateTimerType('time-to-end'); - expect(timerType).toBe(TimerType.TimeToEnd); + const timerType = validateTimerType('count-up'); + expect(timerType).toBe(TimerType.CountUp); }); it('returns fallback otherwise', () => { const emptyType = validateTimerType('', TimerType.Clock); From df1cf7a96bec6344ae77645207596fcfa5906f9a Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Mon, 16 Dec 2024 10:38:52 +0100 Subject: [PATCH 02/12] feat: parse timeToEnd from excel --- .../import-map/importMapUtils.ts | 2 ++ .../sources-panel/preview/PreviewRundown.tsx | 3 +++ .../server/src/utils/__tests__/parser.test.ts | 24 ++++++++++++++----- apps/server/src/utils/parser.ts | 8 ++++++- .../__tests__/spreadsheetImport.test.ts | 2 ++ .../spreadsheet-import/spreadsheetImport.ts | 1 + 6 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts b/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts index a3fb75715..506d6c41c 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts +++ b/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts @@ -11,6 +11,7 @@ export const namedImportMap = { Duration: 'duration', Cue: 'cue', Title: 'title', + 'Time to end': 'time to end', 'Is Public': 'public', Skip: 'skip', Note: 'notes', @@ -47,6 +48,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap { duration: namedImportMap.Duration, cue: namedImportMap.Cue, title: namedImportMap.Title, + isTimeToEnd: namedImportMap['Time to end'], isPublic: namedImportMap['Is Public'], skip: namedImportMap.Skip, note: namedImportMap.Note, diff --git a/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx index 1ede1ef04..f47a4fa29 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx +++ b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx @@ -40,6 +40,7 @@ export default function PreviewRundown(props: PreviewRundownProps) { Duration Warning Time Danger Time + Is Time to End Is Public Skip Colour @@ -71,6 +72,7 @@ export default function PreviewRundown(props: PreviewRundownProps) { } eventIndex += 1; const colour = event.colour ? getAccessibleColour(event.colour) : {}; + const isTimeToEnd = booleanToText(event.isTimeToEnd); const isPublic = booleanToText(event.isPublic); const skip = booleanToText(event.skip); @@ -93,6 +95,7 @@ export default function PreviewRundown(props: PreviewRundownProps) { {millisToString(event.duration)} {millisToString(event.timeWarning)} {millisToString(event.timeDanger)} + {isTimeToEnd && {isTimeToEnd}} {isPublic && {isPublic}} {skip && {skip}} {event.colour} diff --git a/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index f8b32b1f3..955f48223 100644 --- a/apps/server/src/utils/__tests__/parser.test.ts +++ b/apps/server/src/utils/__tests__/parser.test.ts @@ -775,6 +775,7 @@ describe('getCustomFieldData()', () => { duration: 'duration', cue: 'cue', title: 'title', + isTimeToEnd: 'time to end', isPublic: 'public', skip: 'skip', note: 'notes', @@ -825,6 +826,7 @@ describe('getCustomFieldData()', () => { duration: 'duration', cue: 'cue', title: 'title', + isTimeToEnd: 'time to end', isPublic: 'public', skip: 'skip', note: 'notes', @@ -884,6 +886,7 @@ describe('parseExcel()', () => { 'Title', 'End Action', 'Timer type', + 'Time to end', 'Public', 'Skip', 'Notes', @@ -906,7 +909,8 @@ describe('parseExcel()', () => { 'Guest Welcome', '', '', - 'x', + 'x', // <-- time to end + 'x', // <-- public '', 'Ballyhoo', 'a0', @@ -928,7 +932,8 @@ describe('parseExcel()', () => { 'A song from the hearth', 'load-next', 'clock', - '', + 'x', // <-- time to end + '', // <-- public 'x', 'Rainbow chase', 'b0', @@ -972,6 +977,7 @@ describe('parseExcel()', () => { timerType: 'count-down', endAction: 'none', isPublic: true, + isTimeToEnd: true, skip: false, note: 'Ballyhoo', custom: { @@ -995,6 +1001,7 @@ describe('parseExcel()', () => { timeEnd: 30600000, title: 'A song from the hearth', timerType: 'clock', + isTimeToEnd: true, endAction: 'load-next', isPublic: false, skip: true, @@ -1455,12 +1462,17 @@ describe('parseExcel()', () => { timeDanger: 'danger time', custom: {}, }; + const result = parseExcel(testdata, {}, importMap); expect(result.rundown.length).toBe(2); - expect((result.rundown.at(0) as OntimeEvent).type).toBe(SupportedEvent.Event); - expect((result.rundown.at(0) as OntimeEvent).timerType).toBe(TimerType.CountDown); - expect((result.rundown.at(1) as OntimeEvent).type).toBe(SupportedEvent.Event); - expect((result.rundown.at(1) as OntimeEvent).timerType).toBe(TimerType.CountDown); + expect(result.rundown[0]).toMatchObject({ + type: SupportedEvent.Event, + timerType: TimerType.CountDown, + }); + expect(result.rundown[1]).toMatchObject({ + type: SupportedEvent.Event, + timerType: TimerType.CountDown, + }); }); it('imports as events if timer type is empty or has whitespace', () => { diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index e9822f984..f8b7ae278 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -110,6 +110,7 @@ export const parseExcel = ( // options: booleans let isPublicIndex: number | null = null; let skipIndex: number | null = null; + let isTimeToEndIndex: number | null = null; let linkStartIndex: number | null = null; @@ -159,6 +160,10 @@ export const parseExcel = ( titleIndex = col; rundownMetadata['title'] = { row, col }; }, + [importMap.isTimeToEnd]: (row: number, col: number) => { + isTimeToEndIndex = col; + rundownMetadata['isTimeToEnd'] = { row, col }; + }, [importMap.isPublic]: (row: number, col: number) => { isPublicIndex = col; rundownMetadata['isPublic'] = { row, col }; @@ -226,6 +231,8 @@ export const parseExcel = ( event.duration = parseExcelDate(column); } else if (j === cueIndex) { event.cue = makeString(column, ''); + } else if (j === isTimeToEndIndex) { + event.isTimeToEnd = parseBooleanString(column); } else if (j === isPublicIndex) { event.isPublic = parseBooleanString(column); } else if (j === skipIndex) { @@ -271,7 +278,6 @@ export const parseExcel = ( // if any data was found in row, push to array const keysFound = Object.keys(event).length + Object.keys(eventCustomFields).length; - console.log('keys found ---->', event) if (keysFound > 0) { // if it is a Block type drop all other filed if (isOntimeBlock(event)) { diff --git a/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts b/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts index 2ef3c5005..bad03307c 100644 --- a/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts +++ b/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts @@ -11,6 +11,7 @@ describe('isImportMap()', () => { duration: 'duration', cue: 'cue', title: 'title', + isTimeToEnd: 'time to end', isPublic: 'public', skip: 'skip', note: 'notes', @@ -34,6 +35,7 @@ describe('isImportMap()', () => { duration: 'duration', cue: 'cue', title: 'title', + isTimeToEnd: 'time to end', isPublic: 'public', skip: 'skip', note: 'notes', diff --git a/packages/utils/src/feature/spreadsheet-import/spreadsheetImport.ts b/packages/utils/src/feature/spreadsheet-import/spreadsheetImport.ts index c8ec35a34..830b805e5 100644 --- a/packages/utils/src/feature/spreadsheet-import/spreadsheetImport.ts +++ b/packages/utils/src/feature/spreadsheet-import/spreadsheetImport.ts @@ -11,6 +11,7 @@ export const defaultImportMap = { duration: 'duration', cue: 'cue', title: 'title', + isTimeToEnd: 'time to end', isPublic: 'public', skip: 'skip', note: 'notes', From cfcf6a56847a6f44870a058c48065d48dd53b893 Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Wed, 18 Dec 2024 12:44:48 +0100 Subject: [PATCH 03/12] extract isPlaybackActive to a global util function (#1393) --- apps/server/src/services/timerUtils.ts | 19 +++---------------- .../src/stores/__tests__/runtimeState.test.ts | 1 + apps/server/src/stores/runtimeState.ts | 12 +++++++++--- packages/utils/index.ts | 2 ++ .../utils/src/playback-utils/playbackstate.ts | 10 ++++++++++ 5 files changed, 25 insertions(+), 19 deletions(-) create mode 100644 packages/utils/src/playback-utils/playbackstate.ts diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index df7d3f7c2..3b56e0e12 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,5 +1,5 @@ -import { MaybeNumber, Playback, TimerPhase } from 'ontime-types'; -import { dayInMs } from 'ontime-utils'; +import { MaybeNumber, TimerPhase } from 'ontime-types'; +import { dayInMs, isPlaybackActive } from 'ontime-utils'; import { RuntimeState } from '../stores/runtimeState.js'; /** @@ -187,25 +187,12 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber { return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay; } -/** - * Utility checks whether the playback is considered to be active - * @param state - * @returns - */ -export function isPlaybackActive(state: RuntimeState): boolean { - return ( - state.timer.playback === Playback.Play || - state.timer.playback === Playback.Pause || - state.timer.playback === Playback.Roll - ); -} - /** * Checks running timer to see which phase it currently is in * @param state */ export function getTimerPhase(state: RuntimeState): TimerPhase { - if (!isPlaybackActive(state)) { + if (!isPlaybackActive(state.timer.playback)) { return TimerPhase.None; } diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index 372c0f07f..adf309731 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -175,6 +175,7 @@ describe('mutation on runtimeState', () => { expect(newState.runtime.plannedStart).toBe(0); expect(newState.runtime.plannedEnd).toBe(1500); expect(newState.currentBlock.block).toBeNull(); + expect(newState.runtime.offset).toBe(0); // 2. Start event start(); diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index f11dec294..55b0eba37 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -11,7 +11,14 @@ import { TimerPhase, TimerState, } from 'ontime-types'; -import { calculateDuration, checkIsNow, dayInMs, filterTimedEvents, getPreviousBlock } from 'ontime-utils'; +import { + calculateDuration, + checkIsNow, + dayInMs, + filterTimedEvents, + getPreviousBlock, + isPlaybackActive, +} from 'ontime-utils'; import { clock } from '../services/Clock.js'; import { RestorePoint } from '../services/RestoreService.js'; @@ -21,7 +28,6 @@ import { getExpectedFinish, getRuntimeOffset, getTimerPhase, - isPlaybackActive, } from '../services/timerUtils.js'; import { timerConfig } from '../config/config.js'; import { loadRoll, normaliseRollStart } from '../services/rollUtils.js'; @@ -485,7 +491,7 @@ export function update(): UpdateResult { runtimeState.clock = clock.timeNow(); // we update the clock on every update call // 1. is playback idle? - if (!isPlaybackActive(runtimeState)) { + if (!isPlaybackActive(runtimeState.timer.playback)) { return updateIfIdle(); } diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 1429824ee..138b3c70a 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -89,3 +89,5 @@ export { defaultImportMap, isImportMap, } from './src/feature/spreadsheet-import/spreadsheetImport.js'; + +export { isPlaybackActive } from './src/playback-utils/playbackstate.js'; diff --git a/packages/utils/src/playback-utils/playbackstate.ts b/packages/utils/src/playback-utils/playbackstate.ts new file mode 100644 index 000000000..ed0717fbf --- /dev/null +++ b/packages/utils/src/playback-utils/playbackstate.ts @@ -0,0 +1,10 @@ +import { Playback } from 'ontime-types'; + +/** + * Utility checks whether the playback is considered to be active + * @param state + * @returns + */ +export function isPlaybackActive(state: Playback): boolean { + return state === Playback.Play || state === Playback.Pause || state === Playback.Roll; +} From c7faab00a3cbacae968acc66f3b78845a636e251 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 17 Dec 2024 21:31:26 +0100 Subject: [PATCH 04/12] feat: create offline indicator --- .../features/overview/Overview.module.scss | 17 ++++ .../client/src/features/overview/Overview.tsx | 86 +++++++++---------- 2 files changed, 58 insertions(+), 45 deletions(-) diff --git a/apps/client/src/features/overview/Overview.module.scss b/apps/client/src/features/overview/Overview.module.scss index 4aa87687e..92847e298 100644 --- a/apps/client/src/features/overview/Overview.module.scss +++ b/apps/client/src/features/overview/Overview.module.scss @@ -4,6 +4,23 @@ display: flex; } +.isOffline { + .info { + opacity: $opacity-disabled; + } + &::after { + content: 'Disconnected'; + position: absolute; + padding-inline: 0.5rem; + bottom: 0.5rem; + right: 0.5rem; + background-color: $red-700; + border-radius: 2px; + font-size: calc(1rem - 2px); + z-index: 10; + } +} + .nav { display: flex; gap: 0.5rem; diff --git a/apps/client/src/features/overview/Overview.tsx b/apps/client/src/features/overview/Overview.tsx index 3a86e8764..8bcb3a39f 100644 --- a/apps/client/src/features/overview/Overview.tsx +++ b/apps/client/src/features/overview/Overview.tsx @@ -1,10 +1,10 @@ -import { memo, useMemo } from 'react'; +import { memo, PropsWithChildren, ReactNode, useMemo } from 'react'; import { millisToString } from 'ontime-utils'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; -import { useRuntimeOverview, useRuntimePlaybackOverview, useTimer } from '../../common/hooks/useSocket'; +import { useIsOnline, useRuntimeOverview, useRuntimePlaybackOverview, useTimer } from '../../common/hooks/useSocket'; import useProjectData from '../../common/hooks-query/useProjectData'; -import { enDash } from '../../common/utils/styleUtils'; +import { cx, enDash } from '../../common/utils/styleUtils'; import { TimeColumn, TimeRow } from './composite/TimeLayout'; import { calculateEndAndDaySpan, formatedTime, getOffsetText } from './overviewUtils'; @@ -13,7 +13,7 @@ import style from './Overview.module.scss'; export const EditorOverview = memo(_EditorOverview); -function _EditorOverview({ children }: { children: React.ReactNode }) { +function _EditorOverview({ children }: PropsWithChildren) { const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview(); const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); @@ -23,36 +23,26 @@ function _EditorOverview({ children }: { children: React.ReactNode }) { const expectedEndText = formatedTime(maybeExpectedEnd); return ( -

- -
{children}
-
- -
- - -
- - - -
- - -
-
-
-
+ + +
+ + +
+ + + +
+ + +
+
); } export const CuesheetOverview = memo(_CuesheetOverview); -function _CuesheetOverview({ children }: { children: React.ReactNode }) { +function _CuesheetOverview({ children }: PropsWithChildren) { const { plannedEnd, expectedEnd } = useRuntimeOverview(); const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); @@ -62,23 +52,29 @@ function _CuesheetOverview({ children }: { children: React.ReactNode }) { const expectedEndText = formatedTime(maybeExpectedEnd); return ( -
+ + + + +
+ + +
+
+ ); +} + +interface OverviewWrapperProps { + navElements: ReactNode; +} + +function OverviewWrapper({ navElements, children }: PropsWithChildren) { + const { isOnline } = useIsOnline(); + return ( +
-
{children}
-
- - - -
- - -
-
+
{navElements}
+
{children}
); From ec823e0095efa8eaf975fb720d349c8857827a09 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 17 Dec 2024 13:21:16 +0100 Subject: [PATCH 05/12] refactor: improve cuesheet composition refactor: simplify placing events in rundown m --- apps/client/src/common/api/rundown.ts | 4 +- .../client/src/common/hooks/useEventAction.ts | 45 ++-- apps/client/src/common/hooks/useSocket.ts | 14 +- apps/client/src/common/utils/eventsManager.ts | 3 +- apps/client/src/features/rundown/Rundown.tsx | 33 +-- .../src/features/rundown/RundownEntry.tsx | 2 +- .../event-editor/EventEditor.module.scss | 8 - .../rundown/event-editor/EventEditor.tsx | 165 ++++++--------- .../src/views/cuesheet/Cuesheet.module.scss | 150 ------------- apps/client/src/views/cuesheet/Cuesheet.tsx | 197 ------------------ .../src/views/cuesheet/CuesheetPage.tsx | 98 +++++---- .../cuesheet-table-elements/BlockRow.tsx | 18 -- .../CuesheetHeader.tsx | 89 -------- .../cuesheet-table-elements/DelayRow.tsx | 22 -- .../cuesheet-table-elements/EventRow.tsx | 67 ------ .../cuesheet-table-elements/MultiLineCell.tsx | 37 ---- .../SingleLineCell.tsx | 35 ---- .../cuesheet-table-elements/SortableCell.tsx | 44 ---- .../CuesheetTableSettings.module.scss | 29 --- .../CuesheetTableSettings.tsx | 65 ------ .../src/views/cuesheet/cuesheetCols.tsx | 182 ---------------- .../src/views/cuesheet/useColumnManager.tsx | 46 ---- .../api-data/rundown/rundown.validation.ts | 2 + .../rundown-service/RundownService.ts | 23 +- e2e/tests/features/202-cuesheet.spec.ts | 15 +- .../BackendResponse.type.ts | 12 ++ .../src/definitions/core/OntimeEvent.type.ts | 1 - packages/types/src/index.ts | 8 +- 28 files changed, 223 insertions(+), 1191 deletions(-) delete mode 100644 apps/client/src/views/cuesheet/Cuesheet.module.scss delete mode 100644 apps/client/src/views/cuesheet/Cuesheet.tsx delete mode 100644 apps/client/src/views/cuesheet/cuesheet-table-elements/BlockRow.tsx delete mode 100644 apps/client/src/views/cuesheet/cuesheet-table-elements/CuesheetHeader.tsx delete mode 100644 apps/client/src/views/cuesheet/cuesheet-table-elements/DelayRow.tsx delete mode 100644 apps/client/src/views/cuesheet/cuesheet-table-elements/EventRow.tsx delete mode 100644 apps/client/src/views/cuesheet/cuesheet-table-elements/MultiLineCell.tsx delete mode 100644 apps/client/src/views/cuesheet/cuesheet-table-elements/SingleLineCell.tsx delete mode 100644 apps/client/src/views/cuesheet/cuesheet-table-elements/SortableCell.tsx delete mode 100644 apps/client/src/views/cuesheet/cuesheet-table-settings/CuesheetTableSettings.module.scss delete mode 100644 apps/client/src/views/cuesheet/cuesheet-table-settings/CuesheetTableSettings.tsx delete mode 100644 apps/client/src/views/cuesheet/cuesheetCols.tsx delete mode 100644 apps/client/src/views/cuesheet/useColumnManager.tsx diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 41fd721cb..9d854eee1 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -1,5 +1,5 @@ import axios, { AxiosResponse } from 'axios'; -import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types'; +import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types'; import { apiEntryUrl } from './constants'; @@ -16,7 +16,7 @@ export async function fetchNormalisedRundown(): Promise { /** * HTTP request to post new event */ -export async function requestPostEvent(data: Partial): Promise> { +export async function requestPostEvent(data: TransientEventPayload): Promise> { return axios.post(rundownPath, data); } diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEventAction.ts index 54e0af182..d094ff9b4 100644 --- a/apps/client/src/common/hooks/useEventAction.ts +++ b/apps/client/src/common/hooks/useEventAction.ts @@ -1,6 +1,14 @@ import { useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { isOntimeEvent, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types'; +import { + isOntimeEvent, + OntimeBlock, + OntimeDelay, + OntimeEvent, + OntimeRundownEntry, + RundownCached, + TransientEventPayload, +} from 'ontime-types'; import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils'; import { RUNDOWN } from '../api/constants'; @@ -19,6 +27,16 @@ import { import { logAxiosError } from '../api/utils'; import { useEditorSettings } from '../stores/editorSettings'; +export type EventOptions = Partial<{ + // options to any new block (event / delay / block) + after: string; + before: string; + // options to blocks of type OntimeEvent + defaultPublic: boolean; + linkPrevious: boolean; + lastEventId: string; +}>; + /** * @description Set of utilities for events //TODO: should this be called useEntryAction and so on */ @@ -47,31 +65,19 @@ export const useEventAction = () => { networkMode: 'always', }); - // options to any new block (event / delay / block) - type BaseOptions = { - after?: string; - }; - - // options to blocks of type OntimeEvent - type EventOptions = BaseOptions & - Partial<{ - defaultPublic: boolean; - linkPrevious: boolean; - lastEventId: string; - }>; - /** * Adds an event to rundown */ const addEvent = useCallback( - async (event: Partial, options?: EventOptions) => { - const newEvent: Partial = { ...event }; + async (event: Partial, options?: EventOptions) => { + const newEvent: TransientEventPayload = { ...event }; // ************* CHECK OPTIONS specific to events if (isOntimeEvent(newEvent)) { // merge creation time options with event settings const applicationOptions = { after: options?.after, + before: options?.before, defaultPublic: options?.defaultPublic ?? defaultPublic, lastEventId: options?.lastEventId, linkPrevious: options?.linkPrevious ?? linkPrevious, @@ -121,11 +127,16 @@ export const useEventAction = () => { // handle adding options that concern all event type if (options?.after) { + // @ts-expect-error -- not sure how to type this, is a transient property newEvent.after = options.after; } + if (options?.before) { + // @ts-expect-error -- not sure how to type this, is a transient property + newEvent.before = options.before; + } try { - await _addEventMutation.mutateAsync(newEvent); + await _addEventMutation.mutateAsync(newEvent as TransientEventPayload); } catch (error) { logAxiosError('Failed adding event', error); } diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 815b5a392..6bd7c934c 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -149,19 +149,21 @@ export const setAuxTimer = { setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }), }; -export const useCuesheet = () => { +export const useSelectedEventId = () => { const featureSelector = (state: RuntimeStore) => ({ - playback: state.timer.playback, - currentBlockId: state.currentBlock.block?.id ?? null, selectedEventId: state.eventNow?.id ?? null, - selectedEventIndex: state.runtime.selectedEventIndex, - numEvents: state.runtime.numEvents, - titleNow: state.eventNow?.title || '', }); return useRuntimeStore(featureSelector); }; +export const useCurrentBlockId = () => { + const featureSelector = (state: RuntimeStore) => ({ + currentBlockId: state.currentBlock.block?.id ?? null, + }); + return useRuntimeStore(featureSelector); +}; + export const setEventPlayback = { loadEvent: (id: string) => socketSendJson('load', { id }), startEvent: (id: string) => socketSendJson('start', { id }), diff --git a/apps/client/src/common/utils/eventsManager.ts b/apps/client/src/common/utils/eventsManager.ts index eb0370b26..81bf483b6 100644 --- a/apps/client/src/common/utils/eventsManager.ts +++ b/apps/client/src/common/utils/eventsManager.ts @@ -7,7 +7,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types'; * @return {OntimeEvent} clean event */ type ClonedEvent = Omit; -export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => { +export const cloneEvent = (event: OntimeEvent): ClonedEvent => { return { type: SupportedEvent.Event, title: event.title, @@ -23,7 +23,6 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => { isPublic: event.isPublic, skip: event.skip, colour: event.colour, - after, revision: 0, timeWarning: event.timeWarning, timeDanger: event.timeDanger, diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index ce77e8361..3343c7e20 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -6,6 +6,7 @@ import { isOntimeBlock, isOntimeEvent, isPlayableEvent, + MaybeString, PlayableEvent, Playback, RundownCached, @@ -21,7 +22,7 @@ import { isNewLatest, } from 'ontime-utils'; -import { useEventAction } from '../../common/hooks/useEventAction'; +import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction'; import useFollowComponent from '../../common/hooks/useFollowComponent'; import { useRundownEditor } from '../../common/hooks/useSocket'; import { AppMode, useAppMode } from '../../common/stores/appModeStore'; @@ -82,36 +83,36 @@ export default function Rundown({ data }: RundownProps) { const cloneEntry = rundown[copyId]; if (cloneEntry?.type === SupportedEvent.Event) { //if we don't have a cursor add the new event on top - const newEvent = cloneEvent(cloneEntry, adjustedCursor ?? undefined); - addEvent(newEvent); + const newEvent = cloneEvent(cloneEntry); + addEvent(newEvent, { after: adjustedCursor ?? undefined }); } }, [addEvent, order, rundown], ); const insertAtId = useCallback( - (type: SupportedEvent, id: string | null, above = false) => { - const adjustedCursor = above ? getPreviousNormal(rundown, order, id ?? '').entry?.id ?? null : id; - if (adjustedCursor === null) { - // the only thing to do is adding an event at top - addEvent({ type }); - return; - } + (type: SupportedEvent, id: MaybeString, above = false) => { + const options: EventOptions = + id === null + ? {} + : { + after: above ? undefined : id, + before: above ? id : undefined, + }; if (type === SupportedEvent.Event) { const newEvent = { type: SupportedEvent.Event, }; - const options = { - after: adjustedCursor, - lastEventId: adjustedCursor, - }; + if (!above && id) { + options.lastEventId = id; + } addEvent(newEvent, options); } else { - addEvent({ type }, { after: adjustedCursor }); + addEvent({ type }, options); } }, - [rundown, order, addEvent], + [addEvent], ); const selectBlock = useCallback( diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 1166961f1..806d2f62c 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -115,7 +115,7 @@ export default function RundownEntry(props: RundownEntryProps) { return deleteEvent([data.id]); } case 'clone': { - const newEvent = cloneEvent(data as OntimeEvent, data.id); + const newEvent = cloneEvent(data as OntimeEvent); addEvent(newEvent, { after: data.id }); break; } diff --git a/apps/client/src/features/rundown/event-editor/EventEditor.module.scss b/apps/client/src/features/rundown/event-editor/EventEditor.module.scss index 7f9b8469b..6e274a102 100644 --- a/apps/client/src/features/rundown/event-editor/EventEditor.module.scss +++ b/apps/client/src/features/rundown/event-editor/EventEditor.module.scss @@ -20,14 +20,6 @@ overflow-y: auto; } -.footer { - border-top: 1px solid $white-10; - padding-top: 1rem; - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - .timeSettings { display: flex; flex-direction: column; diff --git a/apps/client/src/features/rundown/event-editor/EventEditor.tsx b/apps/client/src/features/rundown/event-editor/EventEditor.tsx index 15f8efa3f..05a111059 100644 --- a/apps/client/src/features/rundown/event-editor/EventEditor.tsx +++ b/apps/client/src/features/rundown/event-editor/EventEditor.tsx @@ -1,15 +1,12 @@ -import { CSSProperties, memo, useCallback, useEffect, useState } from 'react'; +import { CSSProperties, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Button } from '@chakra-ui/react'; -import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types'; +import { CustomFieldLabel, OntimeEvent } from 'ontime-types'; -import CopyTag from '../../../common/components/copy-tag/CopyTag'; import { useEventAction } from '../../../common/hooks/useEventAction'; import useCustomFields from '../../../common/hooks-query/useCustomFields'; -import useRundown from '../../../common/hooks-query/useRundown'; import { getAccessibleColour } from '../../../common/utils/styleUtils'; import * as Editor from '../../editors/editor-utils/EditorUtils'; -import { useEventSelection } from '../useEventSelection'; import EventEditorTimes from './composite/EventEditorTimes'; import EventEditorTitles from './composite/EventEditorTitles'; @@ -22,35 +19,17 @@ export type EventEditorSubmitActions = keyof OntimeEvent; export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel; -export default function EventEditor() { - const selectedEvents = useEventSelection((state) => state.selectedEvents); - const { data } = useRundown(); +interface EventEditorProps { + event: OntimeEvent; +} + +export default function EventEditor(props: EventEditorProps) { + const { event } = props; const { data: customFields } = useCustomFields(); - const { order, rundown } = data; const { updateEvent } = useEventAction(); const [_searchParams, setSearchParams] = useSearchParams(); - const [event, setEvent] = useState(null); - - useEffect(() => { - if (order.length === 0) { - setEvent(null); - return; - } - - const selectedEventId = order.find((eventId) => selectedEvents.has(eventId)); - if (!selectedEventId) { - setEvent(null); - return; - } - const event = rundown[selectedEventId]; - - if (event && isOntimeEvent(event)) { - setEvent(event); - } else { - setEvent(null); - } - }, [order, rundown, selectedEvents]); + const isEditor = window.location.pathname.includes('editor'); const handleSubmit = useCallback( (field: EditorUpdateFields, value: string) => { @@ -73,87 +52,61 @@ export default function EventEditor() { } return ( -
-
- - -
-
- Custom Fields +
+ + +
+
+ Custom Fields + {isEditor && ( -
- {Object.keys(customFields).map((fieldKey) => { - const key = `${event.id}-${fieldKey}`; - const fieldName = `custom-${fieldKey}`; - const initialValue = event.custom[fieldKey] ?? ''; - const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour); - const labelText = customFields[fieldKey].label; - - return ( - - ); - })} + )}
+ {Object.keys(customFields).map((fieldKey) => { + const key = `${event.id}-${fieldKey}`; + const fieldName = `custom-${fieldKey}`; + const initialValue = event.custom[fieldKey] ?? ''; + const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour); + const labelText = customFields[fieldKey].label; + + return ( + + ); + })}
- -
- ); -} - -interface EventEditorFooterProps { - id: string; - cue: string; -} - -const EventEditorFooter = memo(_EventEditorFooter); - -function _EventEditorFooter(props: EventEditorFooterProps) { - const { id, cue } = props; - - const loadById = `/ontime/load/id "${id}"`; - const loadByCue = `/ontime/load/cue "${cue}"`; - - return ( -
- - {loadById} - - - {loadByCue} -
); } diff --git a/apps/client/src/views/cuesheet/Cuesheet.module.scss b/apps/client/src/views/cuesheet/Cuesheet.module.scss deleted file mode 100644 index a5064eb87..000000000 --- a/apps/client/src/views/cuesheet/Cuesheet.module.scss +++ /dev/null @@ -1,150 +0,0 @@ -$table-font-size: calc(1rem - 2px); -$table-header-font-size: calc(1rem - 3px); - -.cuesheetContainer { - grid-area: table; - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - overflow: auto; - padding-bottom: 640px; // allow focus to reach last elements -} - -.cuesheet { - font-size: $table-font-size; - font-weight: 400; - - tr { - display: flex; - } - - th, - td { - margin: 1px; - font-weight: inherit; - font-size: inherit; - text-align: left; - position: relative; - @include ellipsis-overflow; - } -} - -.tableHeader, -.eventRow { - .indexColumn { - min-width: 2rem; - text-align: right; - font-weight: 400; - position: sticky; - left: 0; - z-index: 1; - background-color: $gray-1300; - } -} - -.tableHeader { - position: sticky; - top: 0px; - z-index: 10; - background-color: $ui-black; - font-size: $table-header-font-size; - color: $label-gray;} - -th { - background-color: $gray-1300; - padding-left: 0.25rem; - - &:hover { - .resizer { - width: 0.5rem; - } - } -} - -.eventRow { - vertical-align: top; - - &:hover { - outline: 1px solid $blue-700; - outline-offset: -1px; - } - - td { - background-color: $gray-1250; - border-radius: 2px; - padding: 0.25rem; - } - - &.skip { - text-decoration: line-through; - opacity: $opacity-disabled !important; // fighting inline styles - } -} - -.blockRow { - width: 100%; - background-color: $gray-1350; - font-size: 1rem; - height: 2.5rem; - - td { - align-self: flex-end; - position: sticky; - left: 1rem; - padding: 0.25rem 0; - } -} - -.delayRow { - width: 100%; - color: $ontime-delay-text; - - td { - position: sticky; - left: 47.5%; // center of the screen, ish - padding: 0.5rem 0; - &:first-letter { - text-transform: uppercase; - } - } -} - -.check { - font-size: 1.5rem; - margin: 0 auto; -} - -.time { - display: flex; - gap: 0.5rem; - align-items: center; - - > * { - @include ellipsis-overflow; - } -} - -.delayedTime { - color: $ontime-delay-text; - font-size: calc(1rem - 2px); -} - -.resizer { - cursor: col-resize; - opacity: $opacity-disabled; - display: inline-block; - width: 0; - height: 100%; - position: absolute; - right: 0; - top: 0; - background-color: $action-blue; - - user-select: none; - touch-action: none; - - &:hover { - opacity: 1; - } -} diff --git a/apps/client/src/views/cuesheet/Cuesheet.tsx b/apps/client/src/views/cuesheet/Cuesheet.tsx deleted file mode 100644 index d205da34b..000000000 --- a/apps/client/src/views/cuesheet/Cuesheet.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { useCallback, useRef } from 'react'; -import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; -import Color from 'color'; -import { - CustomFieldLabel, - isOntimeBlock, - isOntimeDelay, - isOntimeEvent, - OntimeRundown, - OntimeRundownEntry, -} from 'ontime-types'; - -import useFollowComponent from '../../common/hooks/useFollowComponent'; -import { getAccessibleColour } from '../../common/utils/styleUtils'; - -import BlockRow from './cuesheet-table-elements/BlockRow'; -import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader'; -import DelayRow from './cuesheet-table-elements/DelayRow'; -import EventRow from './cuesheet-table-elements/EventRow'; -import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings'; -import { useCuesheetOptions } from './cuesheet.options'; -import useColumnManager from './useColumnManager'; - -import style from './Cuesheet.module.scss'; - -interface CuesheetProps { - data: OntimeRundown; - columns: ColumnDef[]; - 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, - handleUpdateCustom, - selectedId, - currentBlockId, -}: CuesheetProps) { - const { followSelected, hideDelays, hidePast, hideIndexColumn } = useCuesheetOptions(); - const { - columnVisibility, - columnOrder, - columnSizing, - resetColumnOrder, - setColumnVisibility, - saveColumnOrder, - setColumnSizing, - } = useColumnManager(columns); - - const selectedRef = useRef(null); - const tableContainerRef = useRef(null); - useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected }); - - const table = useReactTable({ - data, - columns, - columnResizeMode: 'onChange', - state: { - columnOrder, - columnVisibility, - columnSizing, - }, - meta: { - handleUpdate, - handleUpdateCustom, - }, - onColumnVisibilityChange: setColumnVisibility, - onColumnSizingChange: setColumnSizing, - getCoreRowModel: getCoreRowModel(), - }); - - const setAllVisible = () => { - table.toggleAllColumnsVisible(true); - }; - - const resetColumnResizing = () => { - setColumnSizing({}); - }; - - const reorder = useCallback( - (fromId: string, toId: string) => { - // get index of from - const fromIndex = columnOrder.indexOf(fromId); - - // get index of to - const toIndex = columnOrder.indexOf(toId); - - if (toIndex === -1) { - return; - } - - const reorderedCols = [...columnOrder]; - const reorderedItem = reorderedCols.splice(fromIndex, 1); - reorderedCols.splice(toIndex, 0, reorderedItem[0]); - saveColumnOrder(reorderedCols); - }, - [columnOrder, saveColumnOrder], - ); - - const headerGroups = table.getHeaderGroups(); - const rowModel = table.getRowModel(); - const allLeafColumns = table.getAllLeafColumns(); - - let eventIndex = 0; - let isPast = Boolean(selectedId); - - return ( - <> - -
- - - - {rowModel.rows.map((row) => { - const key = row.original.id; - const isSelected = selectedId === key; - if (isSelected) { - isPast = false; - } - - if (isOntimeBlock(row.original)) { - if (isPast && hidePast && key !== currentBlockId) { - return null; - } - return ; - } - if (isOntimeDelay(row.original)) { - if (isPast && hidePast) { - return null; - } - const delayVal = row.original.duration; - if (hideDelays || delayVal === 0) { - return null; - } - - return ; - } - if (isOntimeEvent(row.original)) { - eventIndex++; - const isSelected = key === selectedId; - - if (isPast && hidePast) { - return null; - } - - let rowBgColour: string | undefined; - if (isSelected) { - rowBgColour = '#D20300'; // $red-700 - } else if (row.original.colour) { - try { - // the colour is user defined and might be invalid - const accessibleBackgroundColor = Color(getAccessibleColour(row.original.colour).backgroundColor); - rowBgColour = accessibleBackgroundColor.fade(0.75).hexa(); - } catch (_error) { - /* we do not handle errors here */ - } - } - - return ( - - {row.getVisibleCells().map((cell) => { - return ( - - ); - })} - - ); - } - - // currently there is no scenario where entryType is not handled above, either way... - return null; - })} - -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
- - ); -} diff --git a/apps/client/src/views/cuesheet/CuesheetPage.tsx b/apps/client/src/views/cuesheet/CuesheetPage.tsx index e59184527..2dd92c457 100644 --- a/apps/client/src/views/cuesheet/CuesheetPage.tsx +++ b/apps/client/src/views/cuesheet/CuesheetPage.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { IconButton, useDisclosure } from '@chakra-ui/react'; +import { IconButton, Modal, ModalContent, ModalOverlay, 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, OntimeEvent } from 'ontime-types'; @@ -9,16 +9,17 @@ import ProductionNavigationMenu from '../../common/components/navigation-menu/Pr import EmptyPage from '../../common/components/state/EmptyPage'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import { useEventAction } from '../../common/hooks/useEventAction'; -import { useCuesheet } from '../../common/hooks/useSocket'; import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import useCustomFields from '../../common/hooks-query/useCustomFields'; import { useFlatRundown } from '../../common/hooks-query/useRundown'; import { CuesheetOverview } from '../../features/overview/Overview'; +import CuesheetEventEditor from '../../features/rundown/event-editor/CuesheetEventEditor'; +import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; -import Cuesheet from './Cuesheet'; +import CuesheetTable from './cuesheet-table/CuesheetTable'; import { cuesheetOptions } from './cuesheet.options'; -import { makeCuesheetColumns } from './cuesheetCols'; +import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetCols'; import styles from './CuesheetPage.module.scss'; @@ -28,9 +29,10 @@ export default function CuesheetPage() { const { data: customFields } = useCustomFields(); const [searchParams, setSearchParams] = useSearchParams(); const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure(); + const { isOpen: isEventEditorOpen, onOpen: onEventEditorOpen, onClose: onEventEditorClose } = useDisclosure(); + const [eventId, setEventId] = useState(null); const { updateCustomField, updateEvent } = useEventAction(); - const featureData = useCuesheet(); const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]); useWindowTitle('Cuesheet'); @@ -100,40 +102,64 @@ export default function CuesheetPage() { [flatRundown, rundownStatus, updateEvent], ); + /** + * Handles setting the edit modal target and visibility + */ + const setShowModal = useCallback( + (eventId: string | null) => { + if (eventId) { + setEventId(eventId); + onEventEditorOpen(); + } else { + setEventId(null); + onEventEditorClose(); + } + }, + [onEventEditorClose, onEventEditorOpen], + ); + if (!customFields || !flatRundown || rundownStatus !== 'success') { return ; } return ( -
- - - - } - onClick={onOpen} - /> - } - onClick={showEditFormDrawer} - /> - - - -
+ <> + + + + + + +
+ + + + } + onClick={onOpen} + /> + } + onClick={showEditFormDrawer} + /> + + + + + +
+ ); } diff --git a/apps/client/src/views/cuesheet/cuesheet-table-elements/BlockRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table-elements/BlockRow.tsx deleted file mode 100644 index 7b2b398d0..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-table-elements/BlockRow.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { memo } from 'react'; - -import style from '../Cuesheet.module.scss'; - -interface BlockRowProps { - title: string; -} - -function BlockRow(props: BlockRowProps) { - const { title } = props; - return ( - - {title} - - ); -} - -export default memo(BlockRow); diff --git a/apps/client/src/views/cuesheet/cuesheet-table-elements/CuesheetHeader.tsx b/apps/client/src/views/cuesheet/cuesheet-table-elements/CuesheetHeader.tsx deleted file mode 100644 index 192d88663..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-table-elements/CuesheetHeader.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { - closestCorners, - DndContext, - DragEndEvent, - PointerSensor, - TouchSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; -import { flexRender, HeaderGroup } from '@tanstack/react-table'; -import { OntimeRundownEntry } from 'ontime-types'; - -import { getAccessibleColour } from '../../../common/utils/styleUtils'; - -import { SortableCell } from './SortableCell'; - -import style from '../Cuesheet.module.scss'; - -interface CuesheetHeaderProps { - headerGroups: HeaderGroup[]; - saveColumnOrder: (fromId: string, toId: string) => void; - showIndexColumn: boolean; -} - -export default function CuesheetHeader(props: CuesheetHeaderProps) { - const { headerGroups, saveColumnOrder, showIndexColumn } = props; - - const handleOnDragEnd = (event: DragEndEvent) => { - const { delta, active, over } = event; - - // cancel if delta y is greater than 200 - if (delta.y > 200) return; - // cancel if we do not have an over id - if (over?.id == null) return; - - saveColumnOrder(active.id as string, over.id as string); - }; - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - delay: 100, - tolerance: 50, - }, - }), - useSensor(TouchSensor, { - activationConstraint: { - delay: 100, - tolerance: 50, - }, - }), - ); - - return ( - - {headerGroups.map((headerGroup) => { - const key = headerGroup.id; - - return ( - - - {showIndexColumn && '#'} - - {headerGroup.headers.map((header) => { - const width = header.getSize(); - // @ts-expect-error -- we inject this into react-table - const customBackground = header.column.columnDef?.meta?.colour; - - let customStyles = {}; - if (customBackground) { - const customColour = getAccessibleColour(customBackground); - customStyles = { backgroundColor: customColour.backgroundColor, color: customColour.color }; - } - - return ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ); - })} - - - - ); - })} - - ); -} diff --git a/apps/client/src/views/cuesheet/cuesheet-table-elements/DelayRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table-elements/DelayRow.tsx deleted file mode 100644 index 0149cc991..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-table-elements/DelayRow.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { memo } from 'react'; - -import { millisToDelayString } from '../../../common/utils/dateConfig'; - -import style from '../Cuesheet.module.scss'; - -interface DelayRowProps { - duration: number; -} - -function DelayRow(props: DelayRowProps) { - const { duration } = props; - const delayTime = millisToDelayString(duration, 'expanded'); - - return ( - - {delayTime} - - ); -} - -export default memo(DelayRow); diff --git a/apps/client/src/views/cuesheet/cuesheet-table-elements/EventRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table-elements/EventRow.tsx deleted file mode 100644 index 53fd7f11d..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-table-elements/EventRow.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react'; - -import { getAccessibleColour } from '../../../common/utils/styleUtils'; - -import style from '../Cuesheet.module.scss'; - -const pastOpacity = '0.2'; - -interface EventRowProps { - eventIndex: number; - showIndexColumn: boolean; - isPast?: boolean; - selectedRef?: MutableRefObject; - skip?: boolean; - colour?: string; -} - -function EventRow(props: PropsWithChildren) { - const { children, eventIndex, isPast, selectedRef, skip, colour, showIndexColumn } = props; - const ownRef = useRef(null); - const [isVisible, setIsVisible] = useState(false); - - const textColour = getAccessibleColour(colour); - const bgColour = textColour.backgroundColor; - - 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) { - observer.observe(handleRefCurrent); - } - - return () => { - if (handleRefCurrent) { - observer.unobserve(handleRefCurrent); - } - }; - }, [ownRef, selectedRef]); - - return ( - - - {showIndexColumn && eventIndex} - - {isVisible ? children : null} - - ); -} - -export default memo(EventRow); diff --git a/apps/client/src/views/cuesheet/cuesheet-table-elements/MultiLineCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table-elements/MultiLineCell.tsx deleted file mode 100644 index 9fa159863..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-table-elements/MultiLineCell.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 329d8a4f9..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-table-elements/SingleLineCell.tsx +++ /dev/null @@ -1,35 +0,0 @@ -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/cuesheet-table-elements/SortableCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table-elements/SortableCell.tsx deleted file mode 100644 index 07cb0509c..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-table-elements/SortableCell.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { CSSProperties, ReactNode } from 'react'; -import { useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { Header } from '@tanstack/react-table'; -import { OntimeRundownEntry } from 'ontime-types'; - -import styles from '../Cuesheet.module.scss'; - -interface SortableCellProps { - header: Header; - style: CSSProperties; - children: ReactNode; -} - -export function SortableCell({ header, style, children }: SortableCellProps) { - const { column, colSpan } = header; - - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: column.id, - }); - - // build drag styles - const dragStyle = { - ...style, - opacity: isDragging ? 0.5 : 1, - transform: CSS.Translate.toString(transform), - transition, - }; - - return ( - -
- {children} -
-
- - ); -} diff --git a/apps/client/src/views/cuesheet/cuesheet-table-settings/CuesheetTableSettings.module.scss b/apps/client/src/views/cuesheet/cuesheet-table-settings/CuesheetTableSettings.module.scss deleted file mode 100644 index a036cda7c..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-table-settings/CuesheetTableSettings.module.scss +++ /dev/null @@ -1,29 +0,0 @@ -.tableSettings { - grid-area: settings; - padding-inline: 0.5rem; - display: flex; - gap: 5rem; - font-size: $inner-section-text-size; - - @media (max-width: $small-screen) { - gap: 1rem; - } -} - -.sectionTitle { - text-transform: uppercase; -} - -.row { - display: flex; - flex-wrap: wrap; - column-gap: 1rem; - row-gap: 0.25em; -} - -.option { - cursor: pointer; - display: flex; - align-items: center; - gap: 0.5rem; -} \ No newline at end of file diff --git a/apps/client/src/views/cuesheet/cuesheet-table-settings/CuesheetTableSettings.tsx b/apps/client/src/views/cuesheet/cuesheet-table-settings/CuesheetTableSettings.tsx deleted file mode 100644 index fd7c04424..000000000 --- a/apps/client/src/views/cuesheet/cuesheet-table-settings/CuesheetTableSettings.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { memo, ReactNode } from 'react'; -import { Button, Checkbox } from '@chakra-ui/react'; -import { Column } from '@tanstack/react-table'; -import { OntimeRundownEntry } from 'ontime-types'; - -import * as Editor from '../../../features/editors/editor-utils/EditorUtils'; - -import style from './CuesheetTableSettings.module.scss'; - -// reusable button styles -const buttonProps = { - size: 'xs', - variant: 'ontime-subtle', -}; - -interface CuesheetTableSettingsProps { - columns: Column[]; - handleResetResizing: () => void; - handleResetReordering: () => void; - handleClearToggles: () => void; -} - -function CuesheetTableSettings(props: CuesheetTableSettingsProps) { - const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props; - - return ( -
-
- Toggle column visibility -
- {columns.map((column) => { - const columnHeader = column.columnDef.header; - const visible = column.getIsVisible(); - return ( - - ); - })} -
-
-
- Reset Options -
- - - -
-
-
- ); -} - -export default memo(CuesheetTableSettings); diff --git a/apps/client/src/views/cuesheet/cuesheetCols.tsx b/apps/client/src/views/cuesheet/cuesheetCols.tsx deleted file mode 100644 index f82a55561..000000000 --- a/apps/client/src/views/cuesheet/cuesheetCols.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { useCallback } from 'react'; -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 MultiLineCell from './cuesheet-table-elements/MultiLineCell'; -import SingleLineCell from './cuesheet-table-elements/SingleLineCell'; -import { useCuesheetOptions } from './cuesheet.options'; - -import style from './Cuesheet.module.scss'; - -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) { - const { showDelayedTimes, hideTableSeconds } = useCuesheetOptions(); - const cellValue = (getValue() as number | null) ?? 0; - const delayValue = (original as OntimeEvent)?.delay ?? 0; - - return ( - - - - {delayValue !== 0 && showDelayedTimes && ( - - )} - - ); -} - -function MakeDuration({ getValue }: CellContext) { - const { hideTableSeconds } = useCuesheetOptions(); - const cellValue = (getValue() as number | null) ?? 0; - - return ; -} - -function MakeMultiLineField({ 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 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 ; -} - -export function makeCuesheetColumns(customFields: CustomFields): ColumnDef[] { - const dynamicCustomFields = Object.keys(customFields).map((key) => ({ - accessorKey: key, - id: key, - header: customFields[key].label, - meta: { colour: customFields[key].colour }, - cell: MakeCustomField, - size: 250, - })); - - return [ - { - accessorKey: 'cue', - id: 'cue', - header: 'Cue', - cell: (row) => row.getValue(), - size: 75, - }, - { - accessorKey: 'isPublic', - id: 'isPublic', - header: 'Public', - cell: MakePublic, - size: 45, - }, - { - accessorKey: 'timeStart', - id: 'timeStart', - header: 'Start', - cell: MakeTimer, - size: 75, - }, - { - accessorKey: 'timeEnd', - id: 'timeEnd', - header: 'End', - cell: MakeTimer, - size: 75, - }, - { - accessorKey: 'duration', - id: 'duration', - header: 'Duration', - cell: MakeDuration, - size: 75, - }, - { - accessorKey: 'title', - id: 'title', - header: 'Title', - cell: MakeSingleLineField, - size: 250, - }, - { - accessorKey: 'note', - id: 'note', - header: 'Note', - cell: MakeMultiLineField, - size: 250, - }, - ...dynamicCustomFields, - ]; -} diff --git a/apps/client/src/views/cuesheet/useColumnManager.tsx b/apps/client/src/views/cuesheet/useColumnManager.tsx deleted file mode 100644 index 7e8e33aaa..000000000 --- a/apps/client/src/views/cuesheet/useColumnManager.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import { useLocalStorage } from '@mantine/hooks'; -import { ColumnDef } from '@tanstack/react-table'; -import { OntimeRundownEntry } from 'ontime-types'; - -export default function useColumnManager(columns: ColumnDef[]) { - const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} }); - const [columnOrder, saveColumnOrder] = useLocalStorage({ - key: 'table-order', - defaultValue: columns.map((col) => col.id as string), - }); - const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} }); - - // if the columns change, we update the dataset - useEffect(() => { - let shouldReplace = false; - const newColumns: string[] = []; - - // iterate through columns to see if there are new ids - columns.forEach((column) => { - const columnnId = column.id as string; - if (!shouldReplace && !columnOrder.includes(columnnId)) { - shouldReplace = true; - } - newColumns.push(columnnId); - }); - - if (shouldReplace) { - saveColumnOrder(newColumns); - } - }, [columnOrder, columns, saveColumnOrder]); - - const resetColumnOrder = useCallback(() => { - saveColumnOrder(columns.map((col) => col.id as string)); - }, [columns, saveColumnOrder]); - - return { - columnVisibility, - columnOrder, - columnSizing, - resetColumnOrder, - setColumnVisibility, - saveColumnOrder, - setColumnSizing, - }; -} diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index 2de741ff9..c0b020e8c 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -3,6 +3,8 @@ import { Request, Response, NextFunction } from 'express'; export const rundownPostValidator = [ body('type').isString().exists().isIn(['event', 'delay', 'block']), + body('after').optional().isString(), + body('before').optional().isString(), (req: Request, res: Response, next: NextFunction) => { const errors = validationResult(req); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 3fa8c3ef7..473481794 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -9,6 +9,8 @@ import { isOntimeDelay, isOntimeEvent, OntimeRundown, + PatchWithId, + EventPostPayload, } from 'ontime-types'; import { getCueCandidate } from 'ontime-utils'; @@ -22,8 +24,6 @@ import { runtimeService } from '../runtime-service/RuntimeService.js'; import * as cache from './rundownCache.js'; import { getPlayableEvents, getTimedEvents } from './rundownUtils.js'; -type PatchWithId = (Partial | Partial | Partial) & { id: string }; - type CompleteEntry = T extends Partial ? OntimeEvent @@ -35,12 +35,13 @@ type CompleteEntry = function generateEvent | Partial | Partial>( eventData: T, + afterId?: string, ): CompleteEntry { // we discard any UI provided IDs and add our own const id = cache.getUniqueId(); if (isOntimeEvent(eventData)) { - return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), eventData?.after)) as CompleteEntry; + return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), afterId)) as CompleteEntry; } if (isOntimeDelay(eventData)) { @@ -59,9 +60,11 @@ function generateEvent | Partial | P * @param {object} eventData * @return {OntimeRundownEntry} */ -export async function addEvent(eventData: PatchWithId & { after?: string }): Promise { +export async function addEvent(eventData: EventPostPayload): Promise { // if the user didnt provide an index, we add the event to start let atIndex = 0; + let afterId: string | undefined = eventData?.after; + if (eventData?.after !== undefined) { const previousIndex = cache.getIndexOf(eventData.after); if (previousIndex < 0) { @@ -69,10 +72,20 @@ export async function addEvent(eventData: PatchWithId & { after?: string }): Pro } else { atIndex = previousIndex + 1; } + } else if (eventData?.before !== undefined) { + const previousIndex = cache.getIndexOf(eventData.before); + if (previousIndex < 0) { + logger.warning(LogOrigin.Server, `Could not find event with id ${eventData.before}`); + } else { + atIndex = previousIndex; + if (previousIndex > 0) { + afterId = cache.getPersistedRundown()[atIndex - 1].id; + } + } } // generate a fully formed event from the patch - const eventToAdd = generateEvent(eventData); + const eventToAdd = generateEvent(eventData, afterId); // modify rundown const scopedMutation = cache.mutateCache(cache.add); diff --git a/e2e/tests/features/202-cuesheet.spec.ts b/e2e/tests/features/202-cuesheet.spec.ts index df98815e7..7289286d4 100644 --- a/e2e/tests/features/202-cuesheet.spec.ts +++ b/e2e/tests/features/202-cuesheet.spec.ts @@ -5,14 +5,11 @@ test('cuesheet displays events', async ({ page }) => { 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(); - 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', - ); + await expect(page.locator('#cuesheet')).toBeVisible(); + + // there should be 16 rows in the table (same as the amount of events in the rundown) + const rowCount = await page.locator('#cuesheet tbody tr').count(); + expect(rowCount).toBe(16); }); diff --git a/packages/types/src/api/rundown-controller/BackendResponse.type.ts b/packages/types/src/api/rundown-controller/BackendResponse.type.ts index c6140e611..f2d7538e9 100644 --- a/packages/types/src/api/rundown-controller/BackendResponse.type.ts +++ b/packages/types/src/api/rundown-controller/BackendResponse.type.ts @@ -1,3 +1,4 @@ +import type { OntimeBlock, OntimeDelay, OntimeEvent } from '../../definitions/core/OntimeEvent.type.js'; import type { OntimeRundownEntry } from '../../definitions/core/Rundown.type.js'; type EventId = string; @@ -8,3 +9,14 @@ export interface RundownCached { order: EventId[]; revision: number; } + +export type PatchWithId = Partial & { id: string }; +export type EventPostPayload = Partial & { + after?: string; + before?: string; +}; + +export type TransientEventPayload = Partial & { + after?: string; + before?: string; +}; diff --git a/packages/types/src/definitions/core/OntimeEvent.type.ts b/packages/types/src/definitions/core/OntimeEvent.type.ts index 6cef9896c..801cf295d 100644 --- a/packages/types/src/definitions/core/OntimeEvent.type.ts +++ b/packages/types/src/definitions/core/OntimeEvent.type.ts @@ -9,7 +9,6 @@ export enum SupportedEvent { export type OntimeBaseEvent = { type: SupportedEvent; id: string; - after?: string; // used when creating an event to indicate its position in rundown }; export type OntimeDelay = OntimeBaseEvent & { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 82e37ee90..11d80f8eb 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -55,7 +55,13 @@ export type { ProjectLogoResponse, } from './api/ontime-controller/BackendResponse.type.js'; export type { QuickStartData } from './api/db/db.type.js'; -export type { RundownCached, NormalisedRundown } from './api/rundown-controller/BackendResponse.type.js'; +export type { + EventPostPayload, + NormalisedRundown, + PatchWithId, + RundownCached, + TransientEventPayload, +} from './api/rundown-controller/BackendResponse.type.js'; // SERVER RUNTIME export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js'; From 8116b169b284ba696598aaf8143a134a389454dd Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 17 Dec 2024 13:22:47 +0100 Subject: [PATCH 06/12] refactor: generalise event editor --- .../src/features/rundown/RundownExport.tsx | 4 +- .../event-editor/CuesheetEventEditor.tsx | 44 ++++++++++++++++ .../event-editor/RundownEventEditor.tsx | 50 +++++++++++++++++++ .../composite/EventEditorFooter.module.scss | 7 +++ .../composite/EventEditorFooter.tsx | 30 +++++++++++ 5 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 apps/client/src/features/rundown/event-editor/CuesheetEventEditor.tsx create mode 100644 apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx create mode 100644 apps/client/src/features/rundown/event-editor/composite/EventEditorFooter.module.scss create mode 100644 apps/client/src/features/rundown/event-editor/composite/EventEditorFooter.tsx diff --git a/apps/client/src/features/rundown/RundownExport.tsx b/apps/client/src/features/rundown/RundownExport.tsx index 272d8d932..69cada24f 100644 --- a/apps/client/src/features/rundown/RundownExport.tsx +++ b/apps/client/src/features/rundown/RundownExport.tsx @@ -7,7 +7,7 @@ import { handleLinks } from '../../common/utils/linkUtils'; import { cx } from '../../common/utils/styleUtils'; import { Corner } from '../editors/editor-utils/EditorUtils'; -import EventEditor from './event-editor/EventEditor'; +import RundownEventEditor from './event-editor/RundownEventEditor'; import RundownWrapper from './RundownWrapper'; import style from './RundownExport.module.scss'; @@ -33,7 +33,7 @@ const RundownExport = () => { {!hideSideBar && (
- +
)} diff --git a/apps/client/src/features/rundown/event-editor/CuesheetEventEditor.tsx b/apps/client/src/features/rundown/event-editor/CuesheetEventEditor.tsx new file mode 100644 index 000000000..f6a75d159 --- /dev/null +++ b/apps/client/src/features/rundown/event-editor/CuesheetEventEditor.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { isOntimeEvent, OntimeEvent } from 'ontime-types'; + +import useRundown from '../../../common/hooks-query/useRundown'; + +import EventEditor from './EventEditor'; + +import style from './EventEditor.module.scss'; + +interface CuesheetEventEditorProps { + eventId: string; +} + +export default function CuesheetEventEditor(props: CuesheetEventEditorProps) { + const { eventId } = props; + const { data } = useRundown(); + const { order, rundown } = data; + + const [event, setEvent] = useState(null); + + useEffect(() => { + if (order.length === 0) { + setEvent(null); + return; + } + + const event = rundown[eventId]; + if (event && isOntimeEvent(event)) { + setEvent(event); + } else { + setEvent(null); + } + }, [data, eventId, order, rundown]); + + if (!event) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx b/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx new file mode 100644 index 000000000..f21f5a0ed --- /dev/null +++ b/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import { isOntimeEvent, OntimeEvent } from 'ontime-types'; + +import useRundown from '../../../common/hooks-query/useRundown'; +import { useEventSelection } from '../useEventSelection'; + +import { EventEditorFooter } from './composite/EventEditorFooter'; +import EventEditor from './EventEditor'; +import EventEditorEmpty from './EventEditorEmpty'; + +import style from './EventEditor.module.scss'; + +export default function RundownEventEditor() { + const selectedEvents = useEventSelection((state) => state.selectedEvents); + const { data } = useRundown(); + const { order, rundown } = data; + + const [event, setEvent] = useState(null); + + useEffect(() => { + if (order.length === 0) { + setEvent(null); + return; + } + + const selectedEventId = order.find((eventId) => selectedEvents.has(eventId)); + if (!selectedEventId) { + setEvent(null); + return; + } + const event = rundown[selectedEventId]; + + if (event && isOntimeEvent(event)) { + setEvent(event); + } else { + setEvent(null); + } + }, [order, rundown, selectedEvents]); + + if (!event) { + return ; + } + + return ( +
+ + +
+ ); +} diff --git a/apps/client/src/features/rundown/event-editor/composite/EventEditorFooter.module.scss b/apps/client/src/features/rundown/event-editor/composite/EventEditorFooter.module.scss new file mode 100644 index 000000000..4d94a737e --- /dev/null +++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorFooter.module.scss @@ -0,0 +1,7 @@ +.footer { + border-top: 1px solid $white-10; + padding-top: 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} diff --git a/apps/client/src/features/rundown/event-editor/composite/EventEditorFooter.tsx b/apps/client/src/features/rundown/event-editor/composite/EventEditorFooter.tsx new file mode 100644 index 000000000..aa20e9e38 --- /dev/null +++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorFooter.tsx @@ -0,0 +1,30 @@ +import { memo } from 'react'; + +import CopyTag from '../../../../common/components/copy-tag/CopyTag'; + +import style from './EventEditorFooter.module.scss'; + +interface EventEditorFooterProps { + id: string; + cue: string; +} + +export const EventEditorFooter = memo(_EventEditorFooter); + +function _EventEditorFooter(props: EventEditorFooterProps) { + const { id, cue } = props; + + const loadById = `/ontime/load/id "${id}"`; + const loadByCue = `/ontime/load/cue "${cue}"`; + + return ( +
+ + {loadById} + + + {loadByCue} + +
+ ); +} From 67fa747aae1b23da8639c7c91164350e4fc32bc5 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 17 Dec 2024 13:23:04 +0100 Subject: [PATCH 07/12] refactor: extract cuesheet dnd --- .../cuesheet/cuesheet-dnd/CuesheetDnd.tsx | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx diff --git a/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx b/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx new file mode 100644 index 000000000..64bf10a67 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx @@ -0,0 +1,69 @@ +import { PropsWithChildren } from 'react'; +import { + closestCorners, + DndContext, + DragEndEvent, + PointerSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { ColumnDef } from '@tanstack/react-table'; +import { OntimeRundownEntry } from 'ontime-types'; + +import useColumnManager from '../cuesheet-table/useColumnManager'; + +interface CuesheetDndProps { + columns: ColumnDef[]; +} + +export default function CuesheetDnd(props: PropsWithChildren) { + const { columns, children } = props; + + const { columnOrder, saveColumnOrder } = useColumnManager(columns); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + delay: 100, + tolerance: 50, + }, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 100, + tolerance: 50, + }, + }), + ); + + const handleOnDragEnd = (event: DragEndEvent) => { + const { delta, active, over } = event; + + // cancel if delta y is greater than 200 + if (delta.y > 200) return; + // cancel if we do not have an over id + if (over?.id == null) return; + + // get index of from + const fromIndex = columnOrder.indexOf(active.id as string); + + // get index of to + const toIndex = columnOrder.indexOf(over.id as string); + + if (toIndex === -1) { + return; + } + + const reorderedCols = [...columnOrder]; + const reorderedItem = reorderedCols.splice(fromIndex, 1); + reorderedCols.splice(toIndex, 0, reorderedItem[0]); + saveColumnOrder(reorderedCols); + }; + + return ( + + {children} + + ); +} From 8f30c0a7df7ee8188011ce2e75b13fc1aa3fbc55 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 17 Dec 2024 13:24:43 +0100 Subject: [PATCH 08/12] refactor: restructure element composition --- .../cuesheet-table/CuesheetTable.module.scss | 154 ++++++++++++++++++ .../cuesheet-table-elements/BlockRow.tsx | 27 +++ .../CuesheetHeader.tsx | 52 ++++++ .../cuesheet-table-elements/DelayRow.tsx | 22 +++ .../cuesheet-table-elements/EventRow.tsx | 65 ++++++++ .../cuesheet-table-elements/MultiLineCell.tsx | 37 +++++ .../SingleLineCell.tsx | 35 ++++ .../cuesheet-table-elements/SortableCell.tsx | 44 +++++ .../cuesheet-table-elements/cuesheetCols.tsx | 152 +++++++++++++++++ .../CuesheetTableSettings.module.scss | 29 ++++ .../CuesheetTableSettings.tsx | 65 ++++++++ .../cuesheet-table/useColumnManager.tsx | 46 ++++++ 12 files changed, 728 insertions(+) create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss new file mode 100644 index 000000000..0ea878c77 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss @@ -0,0 +1,154 @@ +$table-font-size: 1rem; +$table-header-font-size: calc(1rem - 2px); + +.cuesheetContainer { + grid-area: table; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: auto; + padding-bottom: 640px; // allow focus to reach last elements +} + +.cuesheet { + font-size: $table-font-size; + font-weight: 400; + + tr { + display: flex; + } + + th, + td { + margin: 1px; + font-weight: inherit; + font-size: inherit; + text-align: left; + position: relative; + @include ellipsis-overflow; + } +} + +.tableHeader, +.eventRow { + .indexColumn { + min-width: 3em; // allow for 3-digit numbers + text-align: right; + font-weight: 400; + font-size: $table-header-font-size; + position: sticky; + left: 0; + z-index: 1; + background-color: $gray-1300; + } + .actionColumn { + width: 2rem; + } +} + +.tableHeader { + position: sticky; + top: 0px; + z-index: 10; + background-color: $ui-black; + font-size: $table-header-font-size; + color: $label-gray;} + +th { + background-color: $gray-1300; + padding-left: 0.25rem; + + &:hover { + .resizer { + width: 0.5rem; + } + } +} + +.eventRow { + vertical-align: top; + + &:hover { + outline: 1px solid $blue-700; + outline-offset: -1px; + } + + td { + background-color: $gray-1250; + border-radius: 2px; + padding: 0.25rem; + } + + &.skip { + text-decoration: line-through; + opacity: $opacity-disabled !important; // fighting inline styles + } +} + +.blockRow { + width: 100%; + background-color: $gray-1350; + font-size: 1rem; + height: 2.5rem; + + td { + align-self: flex-end; + position: sticky; + left: 1rem; + padding: 0.25rem 0; + } +} + +.delayRow { + width: 100%; + color: $ontime-delay-text; + + td { + position: sticky; + left: 47.5%; // center of the screen, ish + padding: 0.5rem 0; + &:first-letter { + text-transform: uppercase; + } + } +} + +.check { + font-size: 1.5rem; + margin: 0 auto; +} + +.time { + display: flex; + gap: 0.5rem; + align-items: center; + + > * { + @include ellipsis-overflow; + } +} + +.delayedTime { + color: $ontime-delay-text; + font-size: calc(1rem - 2px); +} + +.resizer { + cursor: col-resize; + opacity: $opacity-disabled; + display: inline-block; + width: 0; + height: 100%; + position: absolute; + right: 0; + top: 0; + background-color: $action-blue; + + user-select: none; + touch-action: none; + + &:hover { + opacity: 1; + } +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx new file mode 100644 index 000000000..1ef44ff37 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/BlockRow.tsx @@ -0,0 +1,27 @@ +import { memo } from 'react'; + +import { useCurrentBlockId } from '../../../../common/hooks/useSocket'; + +import style from '../CuesheetTable.module.scss'; + +interface BlockRowProps { + hidePast: boolean; + title: string; +} + +function BlockRow(props: BlockRowProps) { + const { hidePast, title } = props; + const { currentBlockId } = useCurrentBlockId(); + + if (hidePast && !currentBlockId) { + return null; + } + + return ( + + {title} + + ); +} + +export default memo(BlockRow); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx new file mode 100644 index 000000000..adc4ac0c8 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx @@ -0,0 +1,52 @@ +import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; +import { flexRender, HeaderGroup } from '@tanstack/react-table'; +import { OntimeRundownEntry } from 'ontime-types'; + +import { getAccessibleColour } from '../../../../common/utils/styleUtils'; + +import { SortableCell } from './SortableCell'; + +import style from '../CuesheetTable.module.scss'; + +interface CuesheetHeaderProps { + headerGroups: HeaderGroup[]; + showIndexColumn: boolean; +} + +export default function CuesheetHeader(props: CuesheetHeaderProps) { + const { headerGroups, showIndexColumn } = props; + + return ( + + {headerGroups.map((headerGroup) => { + const key = headerGroup.id; + + return ( + + {showIndexColumn && '#'} + + + {headerGroup.headers.map((header) => { + const width = header.getSize(); + // @ts-expect-error -- we inject this into react-table + const customBackground = header.column.columnDef?.meta?.colour; + + let customStyles = {}; + if (customBackground) { + const customColour = getAccessibleColour(customBackground); + customStyles = { backgroundColor: customColour.backgroundColor, color: customColour.color }; + } + + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + + ); + })} + + ); +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx new file mode 100644 index 000000000..d8aaa57cf --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx @@ -0,0 +1,22 @@ +import { memo } from 'react'; + +import { millisToDelayString } from '../../../../common/utils/dateConfig'; + +import style from '../CuesheetTable.module.scss'; + +interface DelayRowProps { + duration: number; +} + +function DelayRow(props: DelayRowProps) { + const { duration } = props; + const delayTime = millisToDelayString(duration, 'expanded'); + + return ( + + {delayTime} + + ); +} + +export default memo(DelayRow); 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 new file mode 100644 index 000000000..f5b504ab1 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx @@ -0,0 +1,65 @@ +import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react'; + +import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; + +import style from '../CuesheetTable.module.scss'; + +interface EventRowProps { + eventIndex: number; + showIndexColumn: boolean; + isPast?: boolean; + selectedRef?: MutableRefObject; + skip?: boolean; + colour?: string; +} + +function EventRow(props: PropsWithChildren) { + const { children, eventIndex, isPast, selectedRef, skip, colour, showIndexColumn } = props; + const ownRef = useRef(null); + const [isVisible, setIsVisible] = useState(false); + + const textColour = getAccessibleColour(colour); + const bgColour = textColour.backgroundColor; + + 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) { + observer.observe(handleRefCurrent); + } + + return () => { + if (handleRefCurrent) { + observer.unobserve(handleRefCurrent); + } + }; + }, [ownRef, selectedRef]); + + return ( + + + {showIndexColumn && eventIndex} + + {isVisible ? children : null} + + ); +} + +export default memo(EventRow); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx new file mode 100644 index 000000000..2a2f6b0f6 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/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/cuesheet-table-elements/SingleLineCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx new file mode 100644 index 000000000..f8216d4a8 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/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/cuesheet-table/cuesheet-table-elements/SortableCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx new file mode 100644 index 000000000..30912ea44 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx @@ -0,0 +1,44 @@ +import { CSSProperties, ReactNode } from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Header } from '@tanstack/react-table'; +import { OntimeRundownEntry } from 'ontime-types'; + +import styles from '../CuesheetTable.module.scss'; + +interface SortableCellProps { + header: Header; + style: CSSProperties; + children: ReactNode; +} + +export function SortableCell({ header, style, children }: SortableCellProps) { + const { column, colSpan } = header; + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: column.id, + }); + + // build drag styles + const dragStyle = { + ...style, + opacity: isDragging ? 0.5 : 1, + transform: CSS.Translate.toString(transform), + transition, + }; + + return ( + +
+ {children} +
+
+ + ); +} 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/cuesheetCols.tsx new file mode 100644 index 000000000..fc2c24a24 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx @@ -0,0 +1,152 @@ +import { useCallback } from '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 { useCuesheetOptions } from '../../cuesheet.options'; + +import MultiLineCell from './MultiLineCell'; +import SingleLineCell from './SingleLineCell'; + +import style from '../CuesheetTable.module.scss'; + +function MakeTimer({ getValue, row: { original } }: CellContext) { + const { showDelayedTimes, hideTableSeconds } = useCuesheetOptions(); + const cellValue = (getValue() as number | null) ?? 0; + const delayValue = (original as OntimeEvent)?.delay ?? 0; + + return ( + + + + {delayValue !== 0 && showDelayedTimes && ( + + )} + + ); +} + +function MakeDuration({ getValue }: CellContext) { + const { hideTableSeconds } = useCuesheetOptions(); + const cellValue = (getValue() as number | null) ?? 0; + + return ; +} + +function MakeMultiLineField({ 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 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 ; +} + +export function makeCuesheetColumns(customFields: CustomFields): ColumnDef[] { + const dynamicCustomFields = Object.keys(customFields).map((key) => ({ + accessorKey: key, + id: key, + header: customFields[key].label, + meta: { colour: customFields[key].colour }, + cell: MakeCustomField, + size: 250, + })); + + return [ + { + accessorKey: 'cue', + id: 'cue', + header: 'Cue', + cell: (row) => row.getValue(), + size: 75, + }, + { + accessorKey: 'timeStart', + id: 'timeStart', + header: 'Start', + cell: MakeTimer, + size: 75, + }, + { + accessorKey: 'timeEnd', + id: 'timeEnd', + header: 'End', + cell: MakeTimer, + size: 75, + }, + { + accessorKey: 'duration', + id: 'duration', + header: 'Duration', + cell: MakeDuration, + size: 75, + }, + { + accessorKey: 'title', + id: 'title', + header: 'Title', + cell: MakeSingleLineField, + size: 250, + }, + { + accessorKey: 'note', + id: 'note', + header: 'Note', + cell: MakeMultiLineField, + size: 250, + }, + ...dynamicCustomFields, + ]; +} 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 new file mode 100644 index 000000000..4ef0c2da7 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss @@ -0,0 +1,29 @@ +.tableSettings { + grid-area: settings; + padding-inline: 0.5rem; + display: flex; + gap: 5rem; + font-size: $inner-section-text-size; + + @media (max-width: $small-screen) { + gap: 1rem; + } +} + +.sectionTitle { + text-transform: uppercase; +} + +.row { + display: flex; + flex-wrap: wrap; + column-gap: 1rem; + row-gap: 0.25em; +} + +.option { + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; +} 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 new file mode 100644 index 000000000..3ab8b6d18 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx @@ -0,0 +1,65 @@ +import { memo, ReactNode } from 'react'; +import { Button, Checkbox } from '@chakra-ui/react'; +import { Column } from '@tanstack/react-table'; +import { OntimeRundownEntry } from 'ontime-types'; + +import * as Editor from '../../../../features/editors/editor-utils/EditorUtils'; + +import style from './CuesheetTableSettings.module.scss'; + +// reusable button styles +const buttonProps = { + size: 'xs', + variant: 'ontime-subtle', +}; + +interface CuesheetTableSettingsProps { + columns: Column[]; + handleResetResizing: () => void; + handleResetReordering: () => void; + handleClearToggles: () => void; +} + +function CuesheetTableSettings(props: CuesheetTableSettingsProps) { + const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props; + + return ( +
+
+ Toggle column visibility +
+ {columns.map((column) => { + const columnHeader = column.columnDef.header; + const visible = column.getIsVisible(); + return ( + + ); + })} +
+
+
+ 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 new file mode 100644 index 000000000..7e8e33aaa --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx @@ -0,0 +1,46 @@ +import { useCallback, useEffect } from 'react'; +import { useLocalStorage } from '@mantine/hooks'; +import { ColumnDef } from '@tanstack/react-table'; +import { OntimeRundownEntry } from 'ontime-types'; + +export default function useColumnManager(columns: ColumnDef[]) { + const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} }); + const [columnOrder, saveColumnOrder] = useLocalStorage({ + key: 'table-order', + defaultValue: columns.map((col) => col.id as string), + }); + const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} }); + + // if the columns change, we update the dataset + useEffect(() => { + let shouldReplace = false; + const newColumns: string[] = []; + + // iterate through columns to see if there are new ids + columns.forEach((column) => { + const columnnId = column.id as string; + if (!shouldReplace && !columnOrder.includes(columnnId)) { + shouldReplace = true; + } + newColumns.push(columnnId); + }); + + if (shouldReplace) { + saveColumnOrder(newColumns); + } + }, [columnOrder, columns, saveColumnOrder]); + + const resetColumnOrder = useCallback(() => { + saveColumnOrder(columns.map((col) => col.id as string)); + }, [columns, saveColumnOrder]); + + return { + columnVisibility, + columnOrder, + columnSizing, + resetColumnOrder, + setColumnVisibility, + saveColumnOrder, + setColumnSizing, + }; +} From 2c25910863aca12e6d64a2110766db4b1bcae459 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 17 Dec 2024 14:30:09 +0100 Subject: [PATCH 09/12] refactor: add table actions --- .../src/views/cuesheet/CuesheetPage.tsx | 2 +- .../cuesheet/cuesheet-table/CuesheetTable.tsx | 180 ++++++++++++++++++ .../cuesheet-table/CuesheetTableMenu.tsx | 63 ++++++ 3 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx create mode 100644 apps/client/src/views/cuesheet/cuesheet-table/CuesheetTableMenu.tsx diff --git a/apps/client/src/views/cuesheet/CuesheetPage.tsx b/apps/client/src/views/cuesheet/CuesheetPage.tsx index 2dd92c457..f1402d8c0 100644 --- a/apps/client/src/views/cuesheet/CuesheetPage.tsx +++ b/apps/client/src/views/cuesheet/CuesheetPage.tsx @@ -17,9 +17,9 @@ import CuesheetEventEditor from '../../features/rundown/event-editor/CuesheetEve import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; +import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetCols'; import CuesheetTable from './cuesheet-table/CuesheetTable'; import { cuesheetOptions } from './cuesheet.options'; -import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetCols'; import styles from './CuesheetPage.module.scss'; diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx new file mode 100644 index 000000000..c2f1c4731 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -0,0 +1,180 @@ +import { useRef } from 'react'; +import { IconButton, Menu, MenuButton } from '@chakra-ui/react'; +import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal'; +import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import Color from 'color'; +import { + CustomFieldLabel, + isOntimeBlock, + isOntimeDelay, + isOntimeEvent, + MaybeString, + OntimeRundown, + OntimeRundownEntry, +} from 'ontime-types'; + +import useFollowComponent from '../../../common/hooks/useFollowComponent'; +import { useSelectedEventId } from '../../../common/hooks/useSocket'; +import { getAccessibleColour } from '../../../common/utils/styleUtils'; +import { useCuesheetOptions } from '../cuesheet.options'; + +import BlockRow from './cuesheet-table-elements/BlockRow'; +import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader'; +import DelayRow from './cuesheet-table-elements/DelayRow'; +import EventRow from './cuesheet-table-elements/EventRow'; +import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings'; +import CuesheetTableMenu from './CuesheetTableMenu'; +import useColumnManager from './useColumnManager'; + +import style from './CuesheetTable.module.scss'; + +interface CuesheetTableProps { + data: OntimeRundown; + columns: ColumnDef[]; + handleUpdate: (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: string) => void; + handleUpdateCustom: (rowIndex: number, accessor: CustomFieldLabel, payload: string) => void; + showModal: (eventId: MaybeString) => void; +} + +export default function CuesheetTable(props: CuesheetTableProps) { + const { data, columns, handleUpdate, handleUpdateCustom, showModal } = props; + + const { selectedEventId } = useSelectedEventId(); + const { followSelected, hideDelays, hidePast, hideIndexColumn } = useCuesheetOptions(); + const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } = + useColumnManager(columns); + + const selectedRef = useRef(null); + const tableContainerRef = useRef(null); + useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected }); + + const table = useReactTable({ + data, + columns, + columnResizeMode: 'onChange', + state: { + columnOrder, + columnVisibility, + columnSizing, + }, + meta: { + handleUpdate, + handleUpdateCustom, + }, + onColumnVisibilityChange: setColumnVisibility, + onColumnSizingChange: setColumnSizing, + getCoreRowModel: getCoreRowModel(), + }); + + const setAllVisible = () => { + table.toggleAllColumnsVisible(true); + }; + + const resetColumnResizing = () => { + setColumnSizing({}); + }; + + const headerGroups = table.getHeaderGroups(); + const rowModel = table.getRowModel(); + const allLeafColumns = table.getAllLeafColumns(); + + let eventIndex = 0; + // for the first event, it will be past if there is something selected + let isPast = Boolean(selectedEventId); + + return ( + <> + +
+ + + + {rowModel.rows.map((row, index) => { + const key = row.original.id; + const isSelected = selectedEventId === key; + const entry = row.original; + if (isSelected) { + isPast = false; + } + + if (isOntimeBlock(entry)) { + return ; + } + if (isOntimeDelay(entry)) { + if (isPast && hidePast) { + return null; + } + const delayVal = entry.duration; + if (hideDelays || delayVal === 0) { + return null; + } + + return ; + } + if (isOntimeEvent(entry)) { + eventIndex++; + const isSelected = key === selectedEventId; + + if (isPast && hidePast) { + return null; + } + + let rowBgColour: string | undefined; + if (isSelected) { + rowBgColour = '#D20300'; // $red-700 + } else if (entry.colour) { + try { + // the colour is user defined and might be invalid + const accessibleBackgroundColor = Color(getAccessibleColour(entry.colour).backgroundColor); + rowBgColour = accessibleBackgroundColor.fade(0.75).hexa(); + } catch (_error) { + /* we do not handle errors here */ + } + } + + return ( + + + + {row.getVisibleCells().map((cell) => { + return ( + + ); + })} + + + + ); + } + + // currently there is no scenario where entryType is not handled above, either way... + return null; + })} + +
+ } + variant='ontime-ghosted' + /> + + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ + ); +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTableMenu.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTableMenu.tsx new file mode 100644 index 000000000..e4eabff82 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTableMenu.tsx @@ -0,0 +1,63 @@ +import { MenuDivider, MenuItem, MenuList } from '@chakra-ui/react'; +import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; +import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown'; +import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; +import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline'; +import { IoOptions } from '@react-icons/all-files/io5/IoOptions'; +import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; +import { OntimeEvent, SupportedEvent } from 'ontime-types'; + +import { useEventAction } from '../../../common/hooks/useEventAction'; +import { cloneEvent } from '../../../common/utils/eventsManager'; + +interface CuesheetTableMenuProps { + event: OntimeEvent; + entryIndex: number; + showModal: (entryId: string) => void; +} + +export default function CuesheetTableMenu(props: CuesheetTableMenuProps) { + const { event, entryIndex, showModal } = props; + const { addEvent, reorderEvent, deleteEvent } = useEventAction(); + + const handleCloneEvent = () => { + const newEvent = cloneEvent(event); + try { + addEvent(newEvent, { after: event.id }); + } catch (_error) { + // we do not handle errors here + } + }; + + return ( + + } onClick={() => showModal(event.id)}> + Edit ... + + + } onClick={() => addEvent({ type: SupportedEvent.Event }, { before: event.id })}> + Add event above + + } onClick={() => addEvent({ type: SupportedEvent.Event }, { after: event.id })}> + Add event below + + } onClick={handleCloneEvent}> + Clone event + + + } + onClick={() => reorderEvent(event.id, entryIndex, entryIndex - 1)} + > + Move up + + } onClick={() => reorderEvent(event.id, entryIndex, entryIndex + 1)}> + Move down + + } onClick={() => deleteEvent([event.id])}> + Delete + + + ); +} From 1bdea8e436abf9be9783709ba1193d0cdcb304bb Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 17 Dec 2024 14:31:23 +0100 Subject: [PATCH 10/12] refactor: edit cue field --- .../cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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/cuesheetCols.tsx index fc2c24a24..2a795c793 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/cuesheetCols.tsx @@ -109,7 +109,7 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef row.getValue(), + cell: MakeSingleLineField, size: 75, }, { From 3d37f42fe0b00c76d5b0b0d9c4789be9d0b077f3 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 17 Dec 2024 17:06:25 +0100 Subject: [PATCH 11/12] refactor: tweak styling --- .../cuesheet-table/CuesheetTable.module.scss | 12 +++++++++--- .../cuesheet-table-elements/EventRow.tsx | 9 +++++---- .../cuesheet-table-elements/MultiLineCell.tsx | 3 ++- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss index 0ea878c77..aa2bbdb52 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss @@ -33,6 +33,10 @@ $table-header-font-size: calc(1rem - 2px); .tableHeader, .eventRow { .indexColumn { + display: flex; + align-items: center; + justify-content: end; + min-width: 3em; // allow for 3-digit numbers text-align: right; font-weight: 400; @@ -40,10 +44,11 @@ $table-header-font-size: calc(1rem - 2px); position: sticky; left: 0; z-index: 1; - background-color: $gray-1300; + background-color: $gray-1300; // will be overridden inline } + .actionColumn { - width: 2rem; + width: calc(1.5rem + 0.5rem); // sm button size (--chakra-sizes-6) + 2 * padding } } @@ -53,7 +58,8 @@ $table-header-font-size: calc(1rem - 2px); z-index: 10; background-color: $ui-black; font-size: $table-header-font-size; - color: $label-gray;} + color: $label-gray; +} th { background-color: $gray-1300; 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 f5b504ab1..c8d52f0e1 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,5 @@ import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react'; +import Color from 'color'; import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; @@ -18,9 +19,6 @@ function EventRow(props: PropsWithChildren) { const ownRef = useRef(null); const [isVisible, setIsVisible] = useState(false); - const textColour = getAccessibleColour(colour); - const bgColour = textColour.backgroundColor; - useLayoutEffect(() => { const observer = new IntersectionObserver( ([entry]) => { @@ -48,13 +46,16 @@ function EventRow(props: PropsWithChildren) { }; }, [ownRef, selectedRef]); + const { color, backgroundColor } = getAccessibleColour(colour); + const mutedText = Color(color).fade(0.4).hexa(); + return ( - + {showIndexColumn && eventIndex} {isVisible ? children : null} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx index 2a2f6b0f6..0599b44b5 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx @@ -22,7 +22,8 @@ const MultiLineCell = (props: MultiLineCellProps) => { inputref={ref} rows={1} size='sm' - style={{ padding: 0 }} + padding={0} + fontSize='1rem' transition='none' variant='ontime-transparent' value={value} From 1061ed2a0ea81c591d56ebe138dc0a74acab9056 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 20 Dec 2024 13:30:11 +0100 Subject: [PATCH 12/12] refactor: improve fallback timer consistency --- .../timer-display/TimerDisplay.module.scss | 4 ++ .../playback/timer-display/TimerDisplay.tsx | 2 +- .../client/src/features/overview/Overview.tsx | 61 ++++++++++++++++--- .../overview/composite/TimeLayout.module.scss | 6 +- .../overview/composite/TimeLayout.tsx | 9 +-- apps/client/src/theme/_ontimeStyles.scss | 1 + 6 files changed, 67 insertions(+), 16 deletions(-) diff --git a/apps/client/src/features/control/playback/timer-display/TimerDisplay.module.scss b/apps/client/src/features/control/playback/timer-display/TimerDisplay.module.scss index 0e6b0e992..c93ddff23 100644 --- a/apps/client/src/features/control/playback/timer-display/TimerDisplay.module.scss +++ b/apps/client/src/features/control/playback/timer-display/TimerDisplay.module.scss @@ -16,4 +16,8 @@ &.finished { color: $timer-finished-color; } + + &.muted { + color: $muted-gray; + } } diff --git a/apps/client/src/features/control/playback/timer-display/TimerDisplay.tsx b/apps/client/src/features/control/playback/timer-display/TimerDisplay.tsx index 5234bad7b..945ac5d68 100644 --- a/apps/client/src/features/control/playback/timer-display/TimerDisplay.tsx +++ b/apps/client/src/features/control/playback/timer-display/TimerDisplay.tsx @@ -19,7 +19,7 @@ export default function TimerDisplay(props: TimerDisplayProps) { const isNegative = (time ?? 0) < 0; const display = time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', ''); - const classes = cx([style.timer, isNegative ? style.finished : null]); + const classes = cx([style.timer, isNegative ? style.finished : null, time === null && style.muted]); return
{display}
; } diff --git a/apps/client/src/features/overview/Overview.tsx b/apps/client/src/features/overview/Overview.tsx index 8bcb3a39f..1e10a82d7 100644 --- a/apps/client/src/features/overview/Overview.tsx +++ b/apps/client/src/features/overview/Overview.tsx @@ -4,7 +4,7 @@ import { millisToString } from 'ontime-utils'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import { useIsOnline, useRuntimeOverview, useRuntimePlaybackOverview, useTimer } from '../../common/hooks/useSocket'; import useProjectData from '../../common/hooks-query/useProjectData'; -import { cx, enDash } from '../../common/utils/styleUtils'; +import { cx, enDash, timerPlaceholder } from '../../common/utils/styleUtils'; import { TimeColumn, TimeRow } from './composite/TimeLayout'; import { calculateEndAndDaySpan, formatedTime, getOffsetText } from './overviewUtils'; @@ -26,15 +26,37 @@ function _EditorOverview({ children }: PropsWithChildren) {
- - + +
- - + +
); @@ -57,8 +79,20 @@ function _CuesheetOverview({ children }: PropsWithChildren) {
- - + +
); @@ -100,15 +134,22 @@ function CurrentBlockOverview() { const timeInBlock = formatedTime(currentBlock.startedAt === null ? null : clock - currentBlock.startedAt); - return ; + return ( + + ); } function TimerOverview() { const { current } = useTimer(); - const display = millisToString(current); + const display = millisToString(current, { fallback: timerPlaceholder }); - return ; + return ; } function ProgressOverview() { diff --git a/apps/client/src/features/overview/composite/TimeLayout.module.scss b/apps/client/src/features/overview/composite/TimeLayout.module.scss index 74be3a53b..baeb795a7 100644 --- a/apps/client/src/features/overview/composite/TimeLayout.module.scss +++ b/apps/client/src/features/overview/composite/TimeLayout.module.scss @@ -44,6 +44,10 @@ content: "*"; vertical-align: super; font-size: 0.75em; - color: $blue-500; + color: $info-blue; } } + +.muted { + color: $muted-gray; +} diff --git a/apps/client/src/features/overview/composite/TimeLayout.tsx b/apps/client/src/features/overview/composite/TimeLayout.tsx index aefc3cc5e..181af8da1 100644 --- a/apps/client/src/features/overview/composite/TimeLayout.tsx +++ b/apps/client/src/features/overview/composite/TimeLayout.tsx @@ -7,20 +7,21 @@ import style from './TimeLayout.module.scss'; interface TimeLayoutProps { label: string; value: string; + muted?: boolean; daySpan?: number; className?: string; } -export function TimeColumn({ label, value, className }: TimeLayoutProps) { +export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) { return (
{label} - {value} + {value}
); } -export function TimeRow({ label, value, daySpan, className }: TimeLayoutProps) { +export function TimeRow({ label, value, daySpan, muted, className }: TimeLayoutProps) { return (
{label} @@ -29,7 +30,7 @@ export function TimeRow({ label, value, daySpan, className }: TimeLayoutProps) { {value} ) : ( - {value} + {value} )}
); diff --git a/apps/client/src/theme/_ontimeStyles.scss b/apps/client/src/theme/_ontimeStyles.scss index 46cb5962b..689b26100 100644 --- a/apps/client/src/theme/_ontimeStyles.scss +++ b/apps/client/src/theme/_ontimeStyles.scss @@ -52,6 +52,7 @@ $main-spacing: 2rem; $ontime-font-family: "Open Sans", "Segoe UI", sans-serif; $label-gray: $gray-400; $secondary-text-gray: $gray-400; +$muted-gray: $gray-600; $section-white: $ui-white; $inner-section-text-size: calc(1rem - 2px); $text-body-size: calc(1rem - 1px);