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 ee56a8b5a..6bd7c934c 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); @@ -148,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/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..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, @@ -17,12 +17,12 @@ 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, skip: event.skip, colour: event.colour, - after, revision: 0, timeWarning: event.timeWarning, timeDanger: event.timeDanger, 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/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/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/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/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/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..1e10a82d7 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, timerPlaceholder } 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,48 @@ 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 +74,41 @@ 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}
); @@ -104,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/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 5df99938c..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; } @@ -150,6 +150,7 @@ export default function RundownEntry(props: RundownEntryProps) { if (data.type === SupportedEvent.Event) { return ( { {!hideSideBar && (
- +
)} diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index cf8f21fb8..096d8c0ad 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -24,13 +24,14 @@ import RundownIndicators from './RundownIndicators'; import style from './EventBlock.module.scss'; interface EventBlockProps { + eventId: string; cue: string; timeStart: number; timeEnd: number; duration: number; timeStrategy: TimeStrategy; linkStart: MaybeString; - eventId: string; + isTimeToEnd: boolean; eventIndex: number; isPublic: boolean; endAction: EndAction; @@ -68,6 +69,7 @@ export default function EventBlock(props: EventBlockProps) { duration, timeStrategy, linkStart, + isTimeToEnd, isPublic = true, eventIndex, endAction, @@ -286,6 +288,7 @@ export default function EventBlock(props: EventBlockProps) { timeEnd={timeEnd} duration={duration} linkStart={linkStart} + isTimeToEnd={isTimeToEnd} timeStrategy={timeStrategy} eventId={eventId} eventIndex={eventIndex} diff --git a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx index f0e3621ac..af9454135 100644 --- a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx @@ -23,12 +23,13 @@ import EventBlockProgressBar from './composite/EventBlockProgressBar'; import style from './EventBlock.module.scss'; interface EventBlockInnerProps { + eventId: string; timeStart: number; timeEnd: number; duration: number; timeStrategy: TimeStrategy; linkStart: MaybeString; - eventId: string; + isTimeToEnd: boolean; eventIndex: number; isPublic: boolean; endAction: EndAction; @@ -43,14 +44,15 @@ interface EventBlockInnerProps { isRolling: boolean; } -const EventBlockInner = (props: EventBlockInnerProps) => { +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/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/EventEditor.module.scss b/apps/client/src/features/rundown/event-editor/EventEditor.module.scss index 99d5b4edc..6e274a102 100644 --- a/apps/client/src/features/rundown/event-editor/EventEditor.module.scss +++ b/apps/client/src/features/rundown/event-editor/EventEditor.module.scss @@ -16,18 +16,10 @@ flex: 1; display: flex; flex-direction: column; - gap: 2rem; + gap: 1rem; 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; @@ -38,6 +30,7 @@ display: flex; flex-direction: column; gap: 1rem; + margin-top: 0.5rem; } .decorated { @@ -63,6 +56,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..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,86 +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/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} + +
+ ); +} 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/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); diff --git a/apps/client/src/views/cuesheet/CuesheetPage.tsx b/apps/client/src/views/cuesheet/CuesheetPage.tsx index e59184527..f1402d8c0 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 { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetCols'; +import CuesheetTable from './cuesheet-table/CuesheetTable'; import { cuesheetOptions } from './cuesheet.options'; -import { makeCuesheetColumns } from './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-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} + + ); +} 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.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss similarity index 83% rename from apps/client/src/views/cuesheet/Cuesheet.module.scss rename to apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss index a5064eb87..aa2bbdb52 100644 --- a/apps/client/src/views/cuesheet/Cuesheet.module.scss +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.module.scss @@ -1,5 +1,5 @@ -$table-font-size: calc(1rem - 2px); -$table-header-font-size: calc(1rem - 3px); +$table-font-size: 1rem; +$table-header-font-size: calc(1rem - 2px); .cuesheetContainer { grid-area: table; @@ -33,13 +33,22 @@ $table-header-font-size: calc(1rem - 3px); .tableHeader, .eventRow { .indexColumn { - min-width: 2rem; + display: flex; + align-items: center; + justify-content: end; + + 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; + background-color: $gray-1300; // will be overridden inline + } + + .actionColumn { + width: calc(1.5rem + 0.5rem); // sm button size (--chakra-sizes-6) + 2 * padding } } @@ -49,7 +58,8 @@ $table-header-font-size: calc(1rem - 3px); 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.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx similarity index 53% rename from apps/client/src/views/cuesheet/Cuesheet.tsx rename to apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx index d205da34b..c2f1c4731 100644 --- a/apps/client/src/views/cuesheet/Cuesheet.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -1,4 +1,6 @@ -import { useCallback, useRef } from 'react'; +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 { @@ -6,50 +8,41 @@ import { isOntimeBlock, isOntimeDelay, isOntimeEvent, + MaybeString, OntimeRundown, OntimeRundownEntry, } from 'ontime-types'; -import useFollowComponent from '../../common/hooks/useFollowComponent'; -import { getAccessibleColour } from '../../common/utils/styleUtils'; +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 { useCuesheetOptions } from './cuesheet.options'; +import CuesheetTableMenu from './CuesheetTableMenu'; import useColumnManager from './useColumnManager'; -import style from './Cuesheet.module.scss'; +import style from './CuesheetTable.module.scss'; -interface CuesheetProps { +interface CuesheetTableProps { 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; + showModal: (eventId: MaybeString) => void; } -export default function Cuesheet({ - data, - columns, - handleUpdate, - handleUpdateCustom, - selectedId, - currentBlockId, -}: CuesheetProps) { +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, - saveColumnOrder, - setColumnSizing, - } = useColumnManager(columns); + const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } = + useColumnManager(columns); const selectedRef = useRef(null); const tableContainerRef = useRef(null); @@ -81,32 +74,13 @@ export default function Cuesheet({ 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); + // for the first event, it will be past if there is something selected + let isPast = Boolean(selectedEventId); return ( <> @@ -117,36 +91,34 @@ export default function Cuesheet({ handleClearToggles={setAllVisible} />
- - +
+ - {rowModel.rows.map((row) => { + {rowModel.rows.map((row, index) => { const key = row.original.id; - const isSelected = selectedId === key; + const isSelected = selectedEventId === key; + const entry = row.original; if (isSelected) { isPast = false; } - if (isOntimeBlock(row.original)) { - if (isPast && hidePast && key !== currentBlockId) { - return null; - } - return ; + if (isOntimeBlock(entry)) { + return ; } - if (isOntimeDelay(row.original)) { + if (isOntimeDelay(entry)) { if (isPast && hidePast) { return null; } - const delayVal = row.original.duration; + const delayVal = entry.duration; if (hideDelays || delayVal === 0) { return null; } return ; } - if (isOntimeEvent(row.original)) { + if (isOntimeEvent(entry)) { eventIndex++; - const isSelected = key === selectedId; + const isSelected = key === selectedEventId; if (isPast && hidePast) { return null; @@ -155,10 +127,10 @@ export default function Cuesheet({ let rowBgColour: string | undefined; if (isSelected) { rowBgColour = '#D20300'; // $red-700 - } else if (row.original.colour) { + } else if (entry.colour) { try { // the colour is user defined and might be invalid - const accessibleBackgroundColor = Color(getAccessibleColour(row.original.colour).backgroundColor); + const accessibleBackgroundColor = Color(getAccessibleColour(entry.colour).backgroundColor); rowBgColour = accessibleBackgroundColor.fade(0.75).hexa(); } catch (_error) { /* we do not handle errors here */ @@ -166,23 +138,34 @@ export default function Cuesheet({ } return ( - - {row.getVisibleCells().map((cell) => { - return ( - - ); - })} - + + + + {row.getVisibleCells().map((cell) => { + return ( + + ); + })} + + + ); } 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 + + + ); +} 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 ( + + + + ); +} + +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 ( + + + + ); + })} + + ); +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table-elements/DelayRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx similarity index 73% rename from apps/client/src/views/cuesheet/cuesheet-table-elements/DelayRow.tsx rename to apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx index 0149cc991..d8aaa57cf 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table-elements/DelayRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/DelayRow.tsx @@ -1,8 +1,8 @@ import { memo } from 'react'; -import { millisToDelayString } from '../../../common/utils/dateConfig'; +import { millisToDelayString } from '../../../../common/utils/dateConfig'; -import style from '../Cuesheet.module.scss'; +import style from '../CuesheetTable.module.scss'; interface DelayRowProps { duration: number; diff --git a/apps/client/src/views/cuesheet/cuesheet-table-elements/EventRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx similarity index 77% rename from apps/client/src/views/cuesheet/cuesheet-table-elements/EventRow.tsx rename to apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx index 53fd7f11d..c8d52f0e1 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table-elements/EventRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx @@ -1,10 +1,9 @@ import { memo, MutableRefObject, PropsWithChildren, useLayoutEffect, useRef, useState } from 'react'; +import Color from 'color'; -import { getAccessibleColour } from '../../../common/utils/styleUtils'; +import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; -import style from '../Cuesheet.module.scss'; - -const pastOpacity = '0.2'; +import style from '../CuesheetTable.module.scss'; interface EventRowProps { eventIndex: number; @@ -20,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]) => { @@ -50,13 +46,16 @@ function EventRow(props: PropsWithChildren) { }; }, [ownRef, selectedRef]); + const { color, backgroundColor } = getAccessibleColour(colour); + const mutedText = Color(color).fade(0.4).hexa(); + return ( - {isVisible ? children : null} diff --git a/apps/client/src/views/cuesheet/cuesheet-table-elements/MultiLineCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx similarity index 78% rename from apps/client/src/views/cuesheet/cuesheet-table-elements/MultiLineCell.tsx rename to apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx index 9fa159863..0599b44b5 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table-elements/MultiLineCell.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/MultiLineCell.tsx @@ -1,7 +1,7 @@ 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'; +import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea'; +import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput'; interface MultiLineCellProps { initialValue: string; @@ -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} diff --git a/apps/client/src/views/cuesheet/cuesheet-table-elements/SingleLineCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx similarity index 89% rename from apps/client/src/views/cuesheet/cuesheet-table-elements/SingleLineCell.tsx rename to apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx index 329d8a4f9..f8216d4a8 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table-elements/SingleLineCell.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SingleLineCell.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useRef } from 'react'; import { Input } from '@chakra-ui/react'; -import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; +import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput'; interface SingleLineCellProps { initialValue: string; diff --git a/apps/client/src/views/cuesheet/cuesheet-table-elements/SortableCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx similarity index 95% rename from apps/client/src/views/cuesheet/cuesheet-table-elements/SortableCell.tsx rename to apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx index 07cb0509c..30912ea44 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table-elements/SortableCell.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx @@ -4,7 +4,7 @@ import { CSS } from '@dnd-kit/utilities'; import { Header } from '@tanstack/react-table'; import { OntimeRundownEntry } from 'ontime-types'; -import styles from '../Cuesheet.module.scss'; +import styles from '../CuesheetTable.module.scss'; interface SortableCellProps { header: Header; diff --git a/apps/client/src/views/cuesheet/cuesheetCols.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx similarity index 75% rename from apps/client/src/views/cuesheet/cuesheetCols.tsx rename to apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx index f82a55561..2a795c793 100644 --- a/apps/client/src/views/cuesheet/cuesheetCols.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx @@ -1,38 +1,15 @@ 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 DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; +import RunningTime from '../../../../features/viewers/common/running-time/RunningTime'; +import { useCuesheetOptions } from '../../cuesheet.options'; -import MultiLineCell from './cuesheet-table-elements/MultiLineCell'; -import SingleLineCell from './cuesheet-table-elements/SingleLineCell'; -import { useCuesheetOptions } from './cuesheet.options'; +import MultiLineCell from './MultiLineCell'; +import SingleLineCell from './SingleLineCell'; -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 ( - - ); -} +import style from '../CuesheetTable.module.scss'; function MakeTimer({ getValue, row: { original } }: CellContext) { const { showDelayedTimes, hideTableSeconds } = useCuesheetOptions(); @@ -132,16 +109,9 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef row.getValue(), + cell: MakeSingleLineField, size: 75, }, - { - accessorKey: 'isPublic', - id: 'isPublic', - header: 'Public', - cell: MakePublic, - size: 45, - }, { accessorKey: 'timeStart', id: 'timeStart', diff --git a/apps/client/src/views/cuesheet/cuesheet-table-settings/CuesheetTableSettings.module.scss b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss similarity index 99% rename from apps/client/src/views/cuesheet/cuesheet-table-settings/CuesheetTableSettings.module.scss rename to apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss index a036cda7c..4ef0c2da7 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table-settings/CuesheetTableSettings.module.scss +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss @@ -26,4 +26,4 @@ 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/cuesheet-table-settings/CuesheetTableSettings.tsx similarity index 96% rename from apps/client/src/views/cuesheet/cuesheet-table-settings/CuesheetTableSettings.tsx rename to apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx index fd7c04424..3ab8b6d18 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table-settings/CuesheetTableSettings.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx @@ -3,7 +3,7 @@ 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 * as Editor from '../../../../features/editors/editor-utils/EditorUtils'; import style from './CuesheetTableSettings.module.scss'; diff --git a/apps/client/src/views/cuesheet/useColumnManager.tsx b/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx similarity index 100% rename from apps/client/src/views/cuesheet/useColumnManager.tsx rename to apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx 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/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/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/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..3b56e0e12 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,5 +1,5 @@ -import { MaybeNumber, Playback, TimerPhase, TimerType } 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'; /** @@ -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; } @@ -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/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index dc9d765e0..955f48223 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'); } @@ -774,6 +775,7 @@ describe('getCustomFieldData()', () => { duration: 'duration', cue: 'cue', title: 'title', + isTimeToEnd: 'time to end', isPublic: 'public', skip: 'skip', note: 'notes', @@ -824,6 +826,7 @@ describe('getCustomFieldData()', () => { duration: 'duration', cue: 'cue', title: 'title', + isTimeToEnd: 'time to end', isPublic: 'public', skip: 'skip', note: 'notes', @@ -883,6 +886,7 @@ describe('parseExcel()', () => { 'Title', 'End Action', 'Timer type', + 'Time to end', 'Public', 'Skip', 'Notes', @@ -905,7 +909,8 @@ describe('parseExcel()', () => { 'Guest Welcome', '', '', - 'x', + 'x', // <-- time to end + 'x', // <-- public '', 'Ballyhoo', 'a0', @@ -927,7 +932,8 @@ describe('parseExcel()', () => { 'A song from the hearth', 'load-next', 'clock', - '', + 'x', // <-- time to end + '', // <-- public 'x', 'Rainbow chase', 'b0', @@ -971,6 +977,7 @@ describe('parseExcel()', () => { timerType: 'count-down', endAction: 'none', isPublic: true, + isTimeToEnd: true, skip: false, note: 'Ballyhoo', custom: { @@ -994,6 +1001,7 @@ describe('parseExcel()', () => { timeEnd: 30600000, title: 'A song from the hearth', timerType: 'clock', + isTimeToEnd: true, endAction: 'load-next', isPublic: false, skip: true, @@ -1454,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', () => { @@ -1659,7 +1672,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 +1688,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 +1722,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 +1791,7 @@ describe('parseExcel()', () => { 'false', 'Setup', '', - 'time-to-end', + 'count-down', 'none', '15', '00:05:00', @@ -1778,7 +1807,7 @@ describe('parseExcel()', () => { 'false', 'Meeting 1', '', - 'time-to-end', + 'count-down', 'none', 15, '00:05:00', @@ -1794,7 +1823,7 @@ describe('parseExcel()', () => { 'false', 'Meeting 2', '', - 'time-to-end', + 'count-down', 'none', '13', '5', @@ -1810,7 +1839,7 @@ describe('parseExcel()', () => { 'true', 'Lunch', '', - 'time-to-end', + 'count-down', 'none', 13, 5, @@ -1839,26 +1868,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..f8b7ae278 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, @@ -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) { @@ -273,8 +280,8 @@ export const parseExcel = ( const keysFound = Object.keys(event).length + Object.keys(eventCustomFields).length; 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 +367,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/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/e2e/tests/fixtures/test-sheet.xlsx b/e2e/tests/fixtures/test-sheet.xlsx index aa335bea1..a5f2a8f96 100644 Binary files a/e2e/tests/fixtures/test-sheet.xlsx and b/e2e/tests/fixtures/test-sheet.xlsx differ 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/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..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 & { @@ -29,6 +28,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/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'; 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/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', 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; +} 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);
- {flexRender(cell.column.columnDef.cell, cell.getContext())} - + } + variant='ontime-ghosted' + /> + + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
{title}
{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())} + + ); + })} + +
+ {showIndexColumn && eventIndex}