Merge branch 'master' into port-colision

This commit is contained in:
Alex Christoffer Rasmussen
2024-12-20 18:05:22 +01:00
committed by GitHub
78 changed files with 1261 additions and 783 deletions
+2 -2
View File
@@ -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<RundownCached> {
/**
* HTTP request to post new event
*/
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.post(rundownPath, data);
}
+28 -17
View File
@@ -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<OntimeRundownEntry>, options?: EventOptions) => {
const newEvent: Partial<OntimeRundownEntry> = { ...event };
async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, 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, <after> is a transient property
newEvent.after = options.after;
}
if (options?.before) {
// @ts-expect-error -- not sure how to type this, <before> 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);
}
+9 -6
View File
@@ -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 }),
@@ -15,4 +15,5 @@ export type ViewExtendedTimer = {
clock: number;
timerType: TimerType;
isTimeToEnd: boolean;
};
@@ -7,7 +7,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
* @return {OntimeEvent} clean event
*/
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
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,
@@ -88,7 +88,6 @@ export default function EditorSettingsForm() {
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option>
<option value={TimerType.None}>None</option>
</Select>
@@ -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,
@@ -40,6 +40,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
<th>Duration</th>
<th>Warning Time</th>
<th>Danger Time</th>
<th>Is Time to End</th>
<th>Is Public</th>
<th>Skip</th>
<th>Colour</th>
@@ -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) {
<td>{millisToString(event.duration)}</td>
<td>{millisToString(event.timeWarning)}</td>
<td>{millisToString(event.timeDanger)}</td>
<td className={style.center}>{isTimeToEnd && <Tag>{isTimeToEnd}</Tag>}</td>
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{event.colour}</td>
@@ -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() {
<Tooltip label='Time type: Clock' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoTime className={style.statusIcon} data-active={timerType === TimerType.Clock} />
</Tooltip>
<Tooltip label='Time type: Time to end' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoFlag className={style.statusIcon} data-active={timerType === TimerType.TimeToEnd} />
</Tooltip>
<Tooltip label='Time type: None' openDelay={tooltipDelayMid} shouldWrapChildren>
<IoBan className={style.statusIcon} data-active={timerType === TimerType.None} />
</Tooltip>
<Tooltip
label={isTimeToEnd ? 'Count to schedule' : 'Count from start'}
openDelay={tooltipDelayMid}
shouldWrapChildren
>
<IoFlag className={style.statusIcon} data-active={isTimeToEnd} />
</Tooltip>
</div>
</div>
);
@@ -16,4 +16,8 @@
&.finished {
color: $timer-finished-color;
}
&.muted {
color: $muted-gray;
}
}
@@ -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 <div className={classes}>{display}</div>;
}
@@ -27,7 +27,7 @@
.title {
font-size: 1rem;
color: $label-gray;
color: $ui-white;
}
.label {
@@ -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;
+85 -48
View File
@@ -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 (
<div className={style.overview}>
<ErrorBoundary>
<div className={style.nav}>{children}</div>
<div className={style.info}>
<TitlesOverview />
<div>
<TimeRow label='Planned start' value={formatedTime(plannedStart)} className={style.start} />
<TimeRow label='Actual start' value={formatedTime(actualStart)} className={style.start} />
</div>
<ProgressOverview />
<CurrentBlockOverview />
<RuntimeOverview />
<div>
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
/>
</div>
</div>
</ErrorBoundary>
</div>
<OverviewWrapper navElements={children}>
<TitlesOverview />
<div>
<TimeRow
label='Planned start'
value={formatedTime(plannedStart)}
className={style.start}
muted={plannedStart === null}
/>
<TimeRow
label='Actual start'
value={formatedTime(actualStart)}
className={style.start}
muted={actualStart === null}
/>
</div>
<ProgressOverview />
<CurrentBlockOverview />
<RuntimeOverview />
<div>
<TimeRow
label='Planned end'
value={plannedEndText}
className={style.end}
daySpan={maybePlannedDaySpan}
muted={maybePlannedEnd === null}
/>
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
muted={maybeExpectedEnd === null}
/>
</div>
</OverviewWrapper>
);
}
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 (
<div className={style.overview}>
<OverviewWrapper navElements={children}>
<TitlesOverview />
<TimerOverview />
<RuntimeOverview />
<div>
<TimeRow
label='Planned end'
value={plannedEndText}
className={style.end}
daySpan={maybePlannedDaySpan}
muted={maybePlannedEnd === null}
/>
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
muted={maybeExpectedEnd === null}
/>
</div>
</OverviewWrapper>
);
}
interface OverviewWrapperProps {
navElements: ReactNode;
}
function OverviewWrapper({ navElements, children }: PropsWithChildren<OverviewWrapperProps>) {
const { isOnline } = useIsOnline();
return (
<div className={cx([style.overview, !isOnline && style.isOffline])}>
<ErrorBoundary>
<div className={style.nav}>{children}</div>
<div className={style.info}>
<TitlesOverview />
<TimerOverview />
<RuntimeOverview />
<div>
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
/>
</div>
</div>
<div className={style.nav}>{navElements}</div>
<div className={style.info}>{children}</div>
</ErrorBoundary>
</div>
);
@@ -104,15 +134,22 @@ function CurrentBlockOverview() {
const timeInBlock = formatedTime(currentBlock.startedAt === null ? null : clock - currentBlock.startedAt);
return <TimeColumn label='Time in block' value={timeInBlock} className={style.clock} />;
return (
<TimeColumn
label='Time in block'
value={timeInBlock}
className={style.clock}
muted={currentBlock.startedAt === null}
/>
);
}
function TimerOverview() {
const { current } = useTimer();
const display = millisToString(current);
const display = millisToString(current, { fallback: timerPlaceholder });
return <TimeColumn label='Running timer' value={display} />;
return <TimeColumn label='Running timer' value={display} muted={current === null} />;
}
function ProgressOverview() {
@@ -44,6 +44,10 @@
content: "*";
vertical-align: super;
font-size: 0.75em;
color: $blue-500;
color: $info-blue;
}
}
.muted {
color: $muted-gray;
}
@@ -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 (
<div className={style.column}>
<span className={style.label}>{label}</span>
<span className={cx([style.clock, className])}>{value}</span>
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
</div>
);
}
export function TimeRow({ label, value, daySpan, className }: TimeLayoutProps) {
export function TimeRow({ label, value, daySpan, muted, className }: TimeLayoutProps) {
return (
<div className={style.row}>
<span className={style.label}>{label}</span>
@@ -29,7 +30,7 @@ export function TimeRow({ label, value, daySpan, className }: TimeLayoutProps) {
<span className={cx([style.clock, style.daySpan, className])}>{value}</span>
</Tooltip>
) : (
<span className={cx([style.clock, className])}>{value}</span>
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
)}
</div>
);
+17 -16
View File
@@ -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(
@@ -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 (
<EventBlock
eventId={data.id}
eventIndex={eventIndex}
cue={data.cue}
timeStart={data.timeStart}
@@ -157,7 +158,7 @@ export default function RundownEntry(props: RundownEntryProps) {
duration={data.duration}
timeStrategy={data.timeStrategy}
linkStart={data.linkStart}
eventId={data.id}
isTimeToEnd={data.isTimeToEnd}
isPublic={data.isPublic}
endAction={data.endAction}
timerType={data.timerType}
@@ -7,7 +7,7 @@ import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
import { Corner } from '../editors/editor-utils/EditorUtils';
import EventEditor from './event-editor/EventEditor';
import RundownEventEditor from './event-editor/RundownEventEditor';
import RundownWrapper from './RundownWrapper';
import style from './RundownExport.module.scss';
@@ -33,7 +33,7 @@ const RundownExport = () => {
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<EventEditor />
<RundownEventEditor />
</ErrorBoundary>
</div>
)}
@@ -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}
@@ -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}
/>
</div>
<div className={style.titleSection}>
@@ -122,6 +124,11 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
<EndActionIcon action={endAction} className={style.statusIcon} />
</span>
</Tooltip>
<Tooltip label={`${isTimeToEnd ? 'Time to End' : 'Count from start'}`} openDelay={tooltipDelayMid}>
<span>
<IoFlag className={`${style.statusIcon} ${isTimeToEnd ? style.active : style.disabled}`} />
</span>
</Tooltip>
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} openDelay={tooltipDelayMid}>
<span>
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
@@ -131,7 +138,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
</div>
</>
);
};
}
export default memo(EventBlockInner);
@@ -162,9 +169,5 @@ function TimerIcon(props: { type: TimerType; className: string }) {
if (type === TimerType.None) {
return <IoBan className={className} />;
}
if (type === TimerType.TimeToEnd) {
const classes = cx([style.active, className]);
return <IoFlag className={classes} />;
}
return <IoArrowDown className={className} />;
}
@@ -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<OntimeEvent | null>(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 (
<div className={style.eventEditor} data-testid='editor-container'>
<EventEditor event={event} />
</div>
);
}
@@ -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 {
@@ -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<OntimeEvent | null>(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 (
<div className={style.eventEditor} data-testid='editor-container'>
<div className={style.content}>
<EventEditorTimes
key={`${event.id}-times`}
eventId={event.id}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
duration={event.duration}
timeStrategy={event.timeStrategy}
linkStart={event.linkStart}
delay={event.delay ?? 0}
isPublic={event.isPublic}
endAction={event.endAction}
timerType={event.timerType}
timeWarning={event.timeWarning}
timeDanger={event.timeDanger}
/>
<EventEditorTitles
key={`${event.id}-titles`}
eventId={event.id}
cue={event.cue}
title={event.title}
note={event.note}
colour={event.colour}
handleSubmit={handleSubmit}
/>
<div className={style.column}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Editor.Title>Custom Fields</Editor.Title>
<div className={style.content}>
<EventEditorTimes
key={`${event.id}-times`}
eventId={event.id}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
duration={event.duration}
timeStrategy={event.timeStrategy}
linkStart={event.linkStart}
isTimeToEnd={event.isTimeToEnd}
delay={event.delay ?? 0}
isPublic={event.isPublic}
endAction={event.endAction}
timerType={event.timerType}
timeWarning={event.timeWarning}
timeDanger={event.timeDanger}
/>
<EventEditorTitles
key={`${event.id}-titles`}
eventId={event.id}
cue={event.cue}
title={event.title}
note={event.note}
colour={event.colour}
handleSubmit={handleSubmit}
/>
<div className={style.column}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Editor.Title>Custom Fields</Editor.Title>
{isEditor && (
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
Manage
</Button>
</div>
{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 (
<EventTextArea
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
);
})}
)}
</div>
{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 (
<EventTextArea
key={key}
field={fieldName}
label={labelText}
initialValue={initialValue}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
);
})}
</div>
<EventEditorFooter id={event.id} cue={event.cue} />
</div>
);
}
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 (
<div className={style.footer}>
<CopyTag copyValue={loadById} label='OSC trigger by ID'>
{loadById}
</CopyTag>
<CopyTag copyValue={loadByCue} label='OSC trigger by cue'>
{loadByCue}
</CopyTag>
</div>
);
}
@@ -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<OntimeEvent | null>(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 <EventEditorEmpty />;
}
return (
<div className={style.eventEditor} data-testid='editor-container'>
<EventEditor event={event} />
<EventEditorFooter id={event.id} cue={event.cue} />
</div>
);
}
@@ -0,0 +1,7 @@
.footer {
border-top: 1px solid $white-10;
padding-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@@ -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 (
<div className={style.footer}>
<CopyTag copyValue={loadById} label='OSC trigger by ID'>
{loadById}
</CopyTag>
<CopyTag copyValue={loadByCue} label='OSC trigger by cue'>
{loadByCue}
</CopyTag>
</div>
);
}
@@ -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 (
<div className={style.column}>
<div>
<Editor.Label>Event schedule</Editor.Label>
<div className={style.inline}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
timerType={timerType}
/>
<>
<div className={style.column}>
<div>
<Editor.Label>Event schedule</Editor.Label>
<div className={style.inline}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
isTimeToEnd={isTimeToEnd}
/>
</div>
<div className={style.delayLabel}>{delayLabel}</div>
</div>
<div className={style.delayLabel}>{delayLabel}</div>
</div>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='timeWarning'>Warning Time</Editor.Label>
<TimeInput
id='timeWarning'
name='timeWarning'
submitHandler={handleSubmit}
time={timeWarning}
placeholder='Duration'
/>
</div>
<div>
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
<Select
size='sm'
id='timerType'
name='timerType'
value={timerType}
onChange={(event) => handleSubmit('timerType', event.target.value)}
variant='ontime'
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option>
<option value={TimerType.None}>None</option>
</Select>
</div>
<div>
<Editor.Label htmlFor='timeDanger'>Danger Time</Editor.Label>
<TimeInput
id='timeDanger'
name='timeDanger'
submitHandler={handleSubmit}
time={timeDanger}
placeholder='Duration'
/>
</div>
<div>
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
<Select
id='endAction'
size='sm'
name='endAction'
value={endAction}
onChange={(event) => handleSubmit('endAction', event.target.value)}
variant='ontime'
>
<option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load Next</option>
<option value={EndAction.PlayNext}>Play Next</option>
</Select>
<Editor.Title>Event behaviour</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
<Select
id='endAction'
size='sm'
name='endAction'
value={endAction}
onChange={(event) => handleSubmit('endAction', event.target.value)}
variant='ontime'
>
<option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop rundown</option>
<option value={EndAction.LoadNext}>Load next event</option>
<option value={EndAction.PlayNext}>Play next event</option>
</Select>
</div>
<div>
<Editor.Label htmlFor='timeToEnd'>Target Event Scheduled End</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
id='timeToEnd'
size='md'
isChecked={isTimeToEnd}
onChange={() => handleSubmit('isTimeToEnd', isTimeToEnd)}
variant='ontime'
/>
{isTimeToEnd ? 'On' : 'Off'}
</Editor.Label>
</div>
</div>
</div>
<div className={style.column}>
<Editor.Title>Display options</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
<Select
size='sm'
id='timerType'
name='timerType'
value={timerType}
onChange={(event) => handleSubmit('timerType', event.target.value)}
variant='ontime'
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.Clock}>Clock</option>
<option value={TimerType.None}>None</option>
</Select>
</div>
<div>
<Editor.Label htmlFor='timeWarning'>Warning Time</Editor.Label>
<TimeInput
id='timeWarning'
name='timeWarning'
submitHandler={handleSubmit}
time={timeWarning}
placeholder='Duration'
/>
</div>
<div>
<Editor.Label htmlFor='isPublic'>Event Visibility</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
id='isPublic'
size='md'
isChecked={isPublic}
onChange={() => handleSubmit('isPublic', isPublic)}
variant='ontime'
/>
{isPublic ? 'Public' : 'Private'}
</Editor.Label>
<div>
<Editor.Label htmlFor='isPublic'>Event Visibility</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
id='isPublic'
size='md'
isChecked={isPublic}
onChange={() => handleSubmit('isPublic', isPublic)}
variant='ontime'
/>
{isPublic ? 'Public' : 'Private'}
</Editor.Label>
</div>
<div>
<Editor.Label htmlFor='timeDanger'>Danger Time</Editor.Label>
<TimeInput
id='timeDanger'
name='timeDanger'
submitHandler={handleSubmit}
time={timeDanger}
placeholder='Duration'
/>
</div>
</div>
</div>
</div>
</>
);
};
}
export default memo(EventEditorTimes);
@@ -29,6 +29,7 @@ const EventEditorTitles = (props: EventEditorTitlesProps) => {
return (
<div className={style.column}>
<Editor.Title>Event data</Editor.Title>
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
@@ -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);
@@ -9,6 +9,7 @@ import {
Settings,
SimpleTimerState,
SupportedEvent,
TimerType,
ViewSettings,
} from 'ontime-types';
import { useStore } from 'zustand';
@@ -74,16 +75,14 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
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 = <P extends WithDataProps>(Component: ComponentType<P>) => {
runtime={runtime}
selectedId={selectedId}
settings={settings}
time={TimeManagerType}
time={timeManagerType}
viewSettings={viewSettings}
/>
</>
@@ -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<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>;
type TimerTypeParams = Pick<ViewExtendedTimer, 'isTimeToEnd' | 'timerType' | 'current' | 'elapsed' | 'clock'>;
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;
}
@@ -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 =
@@ -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 &&
+1
View File
@@ -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);
+62 -36
View File
@@ -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<string | null>(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 <EmptyPage text='Loading...' />;
}
return (
<div className={styles.tableWrapper} data-testid='cuesheet'>
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<ViewParamsEditor viewOptions={cuesheetOptions} />
<CuesheetOverview>
<IconButton
aria-label='Toggle navigation'
variant='ontime-subtle-white'
size='lg'
icon={<IoApps />}
onClick={onOpen}
/>
<IconButton
aria-label='Toggle settings'
variant='ontime-subtle-white'
size='lg'
icon={<IoSettingsOutline />}
onClick={showEditFormDrawer}
/>
</CuesheetOverview>
<CuesheetProgress />
<Cuesheet
data={flatRundown}
columns={columns}
handleUpdate={handleUpdate}
handleUpdateCustom={handleUpdateCustom}
//TODO: stabilizer selectedEventId and currentBlockId
selectedId={featureData.selectedEventId}
currentBlockId={featureData.currentBlockId}
/>
</div>
<>
<Modal isOpen={isEventEditorOpen} onClose={onEventEditorClose} variant='ontime'>
<ModalOverlay />
<ModalContent maxWidth='max(640px, 40vw)' padding='1rem'>
<CuesheetEventEditor eventId={eventId!} />
</ModalContent>
</Modal>
<div className={styles.tableWrapper} data-testid='cuesheet'>
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<ViewParamsEditor viewOptions={cuesheetOptions} />
<CuesheetOverview>
<IconButton
aria-label='Toggle navigation'
variant='ontime-subtle-white'
size='lg'
icon={<IoApps />}
onClick={onOpen}
/>
<IconButton
aria-label='Toggle settings'
variant='ontime-subtle-white'
size='lg'
icon={<IoSettingsOutline />}
onClick={showEditFormDrawer}
/>
</CuesheetOverview>
<CuesheetProgress />
<CuesheetDnd columns={columns}>
<CuesheetTable
data={flatRundown}
columns={columns}
handleUpdate={handleUpdate}
handleUpdateCustom={handleUpdateCustom}
showModal={setShowModal}
/>
</CuesheetDnd>
</div>
</>
);
}
@@ -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<OntimeRundownEntry>[];
}
export default function CuesheetDnd(props: PropsWithChildren<CuesheetDndProps>) {
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 (
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleOnDragEnd}>
{children}
</DndContext>
);
}
@@ -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 (
<tr className={style.blockRow}>
<td>{title}</td>
</tr>
);
}
export default memo(BlockRow);
@@ -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<OntimeRundownEntry>[];
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 (
<thead className={style.tableHeader}>
{headerGroups.map((headerGroup) => {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>{showIndexColumn && '#'}</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{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 (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
</DndContext>
);
})}
</thead>
);
}
@@ -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;
@@ -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<OntimeRundownEntry>[];
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<HTMLTableRowElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(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}
/>
<div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet}>
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} showIndexColumn={!hideIndexColumn} />
<table className={style.cuesheet} id='cuesheet'>
<CuesheetHeader headerGroups={headerGroups} showIndexColumn={!hideIndexColumn} />
<tbody>
{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 <BlockRow key={key} title={row.original.title} />;
if (isOntimeBlock(entry)) {
return <BlockRow key={key} title={entry.title} hidePast={isPast && hidePast} />;
}
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 <DelayRow key={key} duration={delayVal} />;
}
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 (
<EventRow
key={key}
eventIndex={eventIndex}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
skip={row.original.skip}
colour={row.original.colour}
showIndexColumn={!hideIndexColumn}
>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</EventRow>
<Menu key={key} variant='ontime-on-dark' size='sm' isLazy>
<EventRow
eventIndex={eventIndex}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
skip={entry.skip}
colour={entry.colour}
showIndexColumn={!hideIndexColumn}
>
<td>
<MenuButton
as={IconButton}
size='xs'
aria-label='Options'
icon={<IoEllipsisHorizontal />}
variant='ontime-ghosted'
/>
</td>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</EventRow>
<CuesheetTableMenu event={entry} entryIndex={index} showModal={showModal} />
</Menu>
);
}
@@ -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 (
<MenuList>
<MenuItem icon={<IoOptions />} onClick={() => showModal(event.id)}>
Edit ...
</MenuItem>
<MenuDivider />
<MenuItem icon={<IoAdd />} onClick={() => addEvent({ type: SupportedEvent.Event }, { before: event.id })}>
Add event above
</MenuItem>
<MenuItem icon={<IoAdd />} onClick={() => addEvent({ type: SupportedEvent.Event }, { after: event.id })}>
Add event below
</MenuItem>
<MenuItem icon={<IoDuplicateOutline />} onClick={handleCloneEvent}>
Clone event
</MenuItem>
<MenuDivider />
<MenuItem
isDisabled={entryIndex < 1}
icon={<IoArrowUp />}
onClick={() => reorderEvent(event.id, entryIndex, entryIndex - 1)}
>
Move up
</MenuItem>
<MenuItem icon={<IoArrowDown />} onClick={() => reorderEvent(event.id, entryIndex, entryIndex + 1)}>
Move down
</MenuItem>
<MenuItem icon={<IoTrash />} onClick={() => deleteEvent([event.id])}>
Delete
</MenuItem>
</MenuList>
);
}
@@ -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 (
<tr className={style.blockRow}>
<td>{title}</td>
</tr>
);
}
export default memo(BlockRow);
@@ -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<OntimeRundownEntry>[];
showIndexColumn: boolean;
}
export default function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups, showIndexColumn } = props;
return (
<thead className={style.tableHeader}>
{headerGroups.map((headerGroup) => {
const key = headerGroup.id;
return (
<tr key={headerGroup.id}>
<th className={style.indexColumn}>{showIndexColumn && '#'}</th>
<th className={style.actionColumn} />
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{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 (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
</SortableContext>
</tr>
);
})}
</thead>
);
}
@@ -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;
@@ -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<EventRowProps>) {
const ownRef = useRef<HTMLTableRowElement>(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<EventRowProps>) {
};
}, [ownRef, selectedRef]);
const { color, backgroundColor } = getAccessibleColour(colour);
const mutedText = Color(color).fade(0.4).hexa();
return (
<tr
className={`${style.eventRow} ${skip ? style.skip : ''}`}
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
className={cx([style.eventRow, skip ?? style.skip])}
style={{ opacity: `${isPast ? '0.2' : '1'}` }}
ref={selectedRef ?? ownRef}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour.color }}>
<td className={style.indexColumn} style={{ backgroundColor, color: mutedText }}>
{showIndexColumn && eventIndex}
</td>
{isVisible ? children : null}
@@ -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}
@@ -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;
@@ -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<OntimeRundownEntry, unknown>;
@@ -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<OntimeRundownEntry, unknown>) {
const update = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
// @ts-expect-error -- we inject this into react-table
table.options.meta?.handleUpdate(row.index, column.id, event.target.checked);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
);
const event = row.original;
if (!isOntimeEvent(event)) {
return null;
}
const isChecked = event.isPublic;
return (
<Checkbox variant='ontime-ondark' onChange={update} isChecked={isChecked} style={{ verticalAlign: 'middle' }} />
);
}
import style from '../CuesheetTable.module.scss';
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
const { showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
@@ -132,16 +109,9 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
accessorKey: 'cue',
id: 'cue',
header: 'Cue',
cell: (row) => row.getValue(),
cell: MakeSingleLineField,
size: 75,
},
{
accessorKey: 'isPublic',
id: 'isPublic',
header: 'Public',
cell: MakePublic,
size: 45,
},
{
accessorKey: 'timeStart',
id: 'timeStart',
@@ -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';
@@ -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);
+14 -1
View File
@@ -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,
@@ -15,6 +15,7 @@ export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockDuration,
linkStart: null,
isTimeToEnd: false,
timeStart: 0,
timeEnd: 0,
duration: 0,
@@ -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,
@@ -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<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) & { id: string };
type CompleteEntry<T> =
T extends Partial<OntimeEvent>
? OntimeEvent
@@ -35,12 +35,13 @@ type CompleteEntry<T> =
function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>(
eventData: T,
afterId?: string,
): CompleteEntry<T> {
// 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<T>;
return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), afterId)) as CompleteEntry<T>;
}
if (isOntimeDelay(eventData)) {
@@ -59,9 +60,11 @@ function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | P
* @param {object} eventData
* @return {OntimeRundownEntry}
*/
export async function addEvent(eventData: PatchWithId & { after?: string }): Promise<OntimeRundownEntry> {
export async function addEvent(eventData: EventPostPayload): Promise<OntimeRundownEntry> {
// 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);
@@ -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,
@@ -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,
+9 -22
View File
@@ -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;
}
@@ -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();
+9 -3
View File
@@ -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();
}
+102 -53
View File
@@ -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,
});
});
});
@@ -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<DatabaseModel> = {
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,
});
});
});
+17 -8
View File
@@ -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<Onti
patchEvent?.duration ?? originalEvent.duration,
patchEvent?.timeStrategy ?? inferStrategy(patchEvent?.timeEnd, patchEvent?.duration, originalEvent.timeStrategy),
);
const maybeLinkStart = patchEvent.linkStart !== undefined ? patchEvent.linkStart : originalEvent.linkStart;
return {
id: originalEvent.id,
@@ -370,9 +376,10 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
timeEnd,
duration,
timeStrategy,
linkStart: validateLinkStart(maybeLinkStart),
linkStart: validateLinkStart(patchEvent.linkStart),
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
isTimeToEnd: typeof patchEvent.isTimeToEnd === 'boolean' ? patchEvent.isTimeToEnd : originalEvent.isTimeToEnd,
isPublic: typeof patchEvent.isPublic === 'boolean' ? patchEvent.isPublic : originalEvent.isPublic,
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
note: makeString(patchEvent.note, originalEvent.note),
@@ -389,17 +396,19 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
/**
* @description Enforces formatting for events
* @param {object} eventArgs - attributes of event
* @param cueFallback
* @param {number} eventIndex - can be a string when we pass the a suggested cue name
* @returns {object|null} - formatted object or null in case is invalid
*/
export const createEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: string): OntimeEvent | null => {
export const createEvent = (eventArgs: Partial<OntimeEvent>, 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);
+27 -10
View File
@@ -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);
}
+6 -9
View File
@@ -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);
});
Binary file not shown.
@@ -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<OntimeEvent | OntimeDelay | OntimeBlock> & { id: string };
export type EventPostPayload = Partial<OntimeRundownEntry> & {
after?: string;
before?: string;
};
export type TransientEventPayload = Partial<OntimeEvent | OntimeDelay | OntimeBlock> & {
after?: string;
before?: string;
};
@@ -1,7 +1,6 @@
export enum TimerType {
CountDown = 'count-down',
CountUp = 'count-up',
TimeToEnd = 'time-to-end',
Clock = 'clock',
None = 'none',
}
@@ -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;
+7 -1
View File
@@ -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';
+2
View File
@@ -89,3 +89,5 @@ export {
defaultImportMap,
isImportMap,
} from './src/feature/spreadsheet-import/spreadsheetImport.js';
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
@@ -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',
@@ -11,6 +11,7 @@ export const defaultImportMap = {
duration: 'duration',
cue: 'cue',
title: 'title',
isTimeToEnd: 'time to end',
isPublic: 'public',
skip: 'skip',
note: 'notes',
@@ -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;
}
@@ -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);