feat: edit groups

This commit is contained in:
Carlos Valente
2025-06-25 06:10:12 +02:00
committed by Carlos Valente
parent 4c08482258
commit 473f50b493
75 changed files with 916 additions and 511 deletions
@@ -9,12 +9,13 @@
border: 1px solid transparent; border: 1px solid transparent;
padding-inline: 0.5em; padding-inline: 0.5em;
outline: none;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background-color: $gray-1100; background-color: $gray-1100;
} }
&:focus { &:focus:not(:read-only) {
background-color: $gray-1000; background-color: $gray-1000;
border: 1px solid $blue-500; border: 1px solid $blue-500;
} }
@@ -26,6 +27,7 @@
&::placeholder { &::placeholder {
color: $gray-500; color: $gray-500;
letter-spacing: 0;
} }
} }
@@ -36,3 +38,7 @@
.large { .large {
height: 2.5rem; height: 2.5rem;
} }
.fluid {
width: 100%;
}
@@ -4,20 +4,23 @@ import { cx } from '../../../utils/styleUtils';
import style from './Input.module.scss'; import style from './Input.module.scss';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
variant?: 'subtle'; variant?: 'subtle';
height?: 'medium' | 'large'; height?: 'medium' | 'large';
fluid?: boolean;
} }
const Input = forwardRef<HTMLInputElement, InputProps>(function Input( const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ className, variant = 'subtle', height = 'medium', ...inputProps }, { className, variant = 'subtle', height = 'medium', fluid, ...inputProps },
ref, ref,
) { ) {
return ( return (
<input <input
ref={ref} ref={ref}
type='text' type='text'
className={cx([style.input, style[variant], style[height], className])} autoCorrect='off'
autoComplete='off'
className={cx([style.input, style[variant], style[height], fluid && style.fluid, className])}
{...inputProps} {...inputProps}
/> />
); );
@@ -0,0 +1,135 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { cx } from '../../../utils/styleUtils';
import Input from '../input/Input';
import style from './TimeInput.module.scss';
interface NullableTimeInputProps<T extends string> {
id?: T;
name: T;
submitHandler: (field: T, value: string) => void;
time?: number | null;
emptyDisplay: string;
placeholder?: string;
disabled?: boolean;
align?: 'left' | 'center';
className?: string;
}
export default function NullableTimeInput<T extends string>(props: NullableTimeInputProps<T>) {
const { id, name, submitHandler, time, emptyDisplay, placeholder, disabled, align = 'center', className } = props;
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false);
/**
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
if (typeof time !== 'number' || isNaN(time)) {
setValue(emptyDisplay);
} else {
setValue(millisToString(time));
}
}, [emptyDisplay, time]);
/**
* @description Selects input text on focus
*/
const handleFocus = useCallback(() => {
inputRef.current?.select();
}, []);
/**
* @description Submit handler
* @param {string} newValue
*/
const handleSubmit = useCallback(
(newValue: string) => {
// skip if user deleted and time is already null
if (newValue === '' && time === null) {
return false;
}
// skip if the value evaluates to the same thing
const valueInMillis = parseUserTime(newValue);
if (valueInMillis === time) {
return false;
}
submitHandler(name, newValue);
return true;
},
[name, submitHandler, time],
);
/**
* @description Prepare time fields
* @param {string} value string to be parsed
*/
const validateAndSubmit = useCallback(
(newValue: string) => {
const success = handleSubmit(newValue);
if (!success) {
resetValue();
}
},
[handleSubmit, resetValue],
);
/**
* @description Handles common keys for submit and cancel
* @param {KeyboardEvent} event
*/
const onKeyDownHandler = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
inputRef.current?.blur();
}
if (event.key === 'Escape') {
ignoreChange.current = true;
inputRef.current?.blur();
resetValue();
}
},
[resetValue],
);
const onBlurHandler = useCallback(
(event: FocusEvent<HTMLInputElement>) => {
if (ignoreChange.current) {
ignoreChange.current = false;
return;
}
validateAndSubmit((event.target as HTMLInputElement).value);
},
[validateAndSubmit],
);
useEffect(() => {
resetValue();
}, [resetValue]);
return (
<Input
id={id}
disabled={disabled}
ref={inputRef}
data-testid={`time-input-${name}`}
className={cx([style.timeInput, className])}
placeholder={placeholder}
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
style={{
textAlign: align,
}}
/>
);
}
@@ -2,6 +2,5 @@
width: 100%; width: 100%;
max-width: 7.5em; max-width: 7.5em;
letter-spacing: 1px; letter-spacing: 1px;
font-size: 1rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -1,7 +1,6 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import { useEmitLog } from '../../../stores/logger';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
import Input from '../input/Input'; import Input from '../input/Input';
@@ -19,8 +18,7 @@ interface TimeInputProps<T extends string> {
} }
export default function TimeInput<T extends string>(props: TimeInputProps<T>) { export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { id, name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props; const { id, name, submitHandler, time, placeholder, disabled, align = 'center', className } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false); const ignoreChange = useRef(false);
@@ -29,16 +27,12 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
* @description Resets input value to given * @description Resets input value to given
*/ */
const resetValue = useCallback(() => { const resetValue = useCallback(() => {
try { if (typeof time !== 'number' || isNaN(time)) {
if (typeof time !== 'number' || isNaN(time)) { setValue('00:00:00');
throw new Error(`Invalid time value: ${time}`); } else {
}
setValue(millisToString(time)); setValue(millisToString(time));
} catch (error) {
setValue(millisToString(0));
emitError(`Unable to parse time ${time}: ${error}`);
} }
}, [emitError, time]); }, [time]);
/** /**
* @description Selects input text on focus * @description Selects input text on focus
@@ -119,9 +113,8 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
); );
useEffect(() => { useEffect(() => {
if (time == null) return;
resetValue(); resetValue();
}, [resetValue, time]); }, [resetValue]);
return ( return (
<Input <Input
@@ -92,17 +92,15 @@
} }
.scrollArrow { .scrollArrow {
background-color: $gray-1000;
color: $ui-white;
width: 100%; width: 100%;
background: canvas;
z-index: 1; z-index: 1;
text-align: center;
cursor: default;
border-radius: 0.375rem;
height: 1rem; height: 1rem;
font-size: 0.75rem; font-size: 0.5rem;
display: flex; display: grid;
align-items: center; place-content: center;
justify-content: center;
&::before { &::before {
content: ''; content: '';
@@ -113,6 +111,7 @@
} }
&[data-direction='up'] { &[data-direction='up'] {
border-radius: 3px 3px 0 0;
&::before { &::before {
top: -100%; top: -100%;
} }
@@ -120,6 +119,7 @@
&[data-direction='down'] { &[data-direction='down'] {
bottom: 0; bottom: 0;
border-radius: 0 0 3px 3px;
&::before { &::before {
bottom: -100%; bottom: -100%;
@@ -9,6 +9,7 @@ interface SelectProps<T extends string | null = string> {
options: { options: {
value: NonNullable<T>; value: NonNullable<T>;
label: string; label: string;
disabled?: boolean; // exposed to allow creating a non-selectable option
}[]; }[];
placeholder?: string; placeholder?: string;
value?: T; value?: T;
@@ -0,0 +1,52 @@
.switch {
position: relative;
display: flex;
align-items: center;
/* Reset and base styles */
appearance: none;
border: 1px solid transparent;
border-radius: 99px;
background-color: $gray-1100;
/* Transitions */
transition: background-color 125ms cubic-bezier(0.26, 0.75, 0.38, 0.45);
/* States */
&[data-checked] {
background-color: $blue-700;
box-shadow: none;
}
&:focus {
border-color: $blue-500;
}
}
.medium {
padding: 2px;
--width: calc(2.5rem + 4px);
--height: 1.5rem;
width: var(--width);
height: var(--height);
}
.large {
padding: 3px;
--width: calc(2.75rem + 6px);
--height: 1.75rem;
width: var(--width);
height: var(--height);
}
.thumb {
aspect-ratio: 1 / 1;
height: 100%;
border-radius: 99px;
background-color: $ui-white;
transition: translate 150ms ease;
&[data-checked] {
translate: calc(var(--width) - var(--height)) 0;
}
}
@@ -0,0 +1,17 @@
import { Switch as BaseSwitch } from '@base-ui-components/react/switch';
import { cx } from '../../utils/styleUtils';
import style from './Switch.module.scss';
interface SwitchProps extends BaseSwitch.Root.Props {
size?: 'medium' | 'large';
}
export default function Switch({ size = 'medium', ...switchProps }: SwitchProps) {
return (
<BaseSwitch.Root className={cx([style.switch, style[size]])} {...switchProps}>
<BaseSwitch.Thumb className={style.thumb} />
</BaseSwitch.Root>
);
}
@@ -790,7 +790,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
order = order.filter((id) => id !== entry.id); order = order.filter((id) => id !== entry.id);
} else { } else {
const parent = entries[entry.parent] as OntimeBlock; const parent = entries[entry.parent] as OntimeBlock;
parent.events = parent.events.filter((event) => event !== entry.id); parent.entries = parent.entries.filter((parentEntry) => parentEntry !== entry.id);
} }
delete entries[entry.id]; delete entries[entry.id];
+14 -14
View File
@@ -171,21 +171,21 @@ export default function Operator() {
return ( return (
<Fragment key={entry.id}> <Fragment key={entry.id}>
<OperatorBlock key={entry.id} title={entry.title} /> <OperatorBlock key={entry.id} title={entry.title} />
{entry.events.map((nestedEventId) => { {entry.entries.map((nestedEntryId) => {
const nestedEvent = data.entries[nestedEventId]; const nestedEntry = data.entries[nestedEntryId];
if (!isOntimeEvent(nestedEvent)) { if (!isOntimeEvent(nestedEntry)) {
return null; return null;
} }
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(nestedEvent); const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(nestedEntry);
// hide past events (if setting) and skipped events // hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) { if (hidePast && isPast) {
return null; return null;
} }
const { mainField, secondaryField, subscribedData } = getEventData( const { mainField, secondaryField, subscribedData } = getEventData(
nestedEvent, nestedEntry,
mainSource, mainSource,
secondarySource, secondarySource,
subscribe, subscribe,
@@ -194,16 +194,16 @@ export default function Operator() {
return ( return (
<OperatorEvent <OperatorEvent
key={nestedEvent.id} key={nestedEntry.id}
id={nestedEvent.id} id={nestedEntry.id}
colour={nestedEvent.colour} colour={nestedEntry.colour}
cue={nestedEvent.cue} cue={nestedEntry.cue}
main={mainField} main={mainField}
secondary={secondaryField} secondary={secondaryField}
timeStart={nestedEvent.timeStart} timeStart={nestedEntry.timeStart}
duration={nestedEvent.duration} duration={nestedEntry.duration}
delay={nestedEvent.delay} delay={nestedEntry.delay}
dayOffset={nestedEvent.dayOffset} dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded} isLinkedToLoaded={isLinkedToLoaded}
isSelected={isSelected} isSelected={isSelected}
isPast={isPast} isPast={isPast}
@@ -36,7 +36,7 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%); background-color: color-mix(in srgb, var(--user-bg, transparent) 15%, transparent 85%);
} }
.entryIndex { .entryIndex {
+3 -3
View File
@@ -39,9 +39,9 @@ import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/clone'; import { cloneEvent } from '../../common/utils/clone';
import BlockBlock from './block-block/BlockBlock';
import BlockEnd from './block-block/BlockEnd';
import QuickAddBlock from './quick-add-block/QuickAddBlock'; import QuickAddBlock from './quick-add-block/QuickAddBlock';
import BlockEnd from './rundown-block/BlockEnd';
import RundownBlock from './rundown-block/RundownBlock';
import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from './rundown.utils'; import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from './rundown.utils';
import RundownEmpty from './RundownEmpty'; import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
@@ -445,7 +445,7 @@ export default function Rundown({ data }: RundownProps) {
/> />
)} )}
{isOntimeBlock(entry) ? ( {isOntimeBlock(entry) ? (
<BlockBlock <RundownBlock
data={entry} data={entry}
hasCursor={hasCursor} hasCursor={hasCursor}
collapsed={getIsCollapsed(entry.id)} collapsed={getIsCollapsed(entry.id)}
@@ -14,8 +14,8 @@ import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { useEmitLog } from '../../common/stores/logger'; import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/clone'; import { cloneEvent } from '../../common/utils/clone';
import DelayBlock from './delay-block/DelayBlock'; import RundownDelay from './rundown-delay/RundownDelay';
import EventBlock from './event-block/EventBlock'; import RundownEvent from './rundown-event/RundownEvent';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
export type EventItemActions = export type EventItemActions =
@@ -48,22 +48,21 @@ interface RundownEntryProps {
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
} }
export default function RundownEntry(props: RundownEntryProps) { export default function RundownEntry({
const { isPast,
isPast, data,
data, loaded,
loaded, hasCursor,
hasCursor, isNext,
isNext, previousEntryId,
previousEntryId, previousEventId,
previousEventId, playback,
playback, isRolling,
isRolling, eventIndex,
eventIndex, isNextDay,
isNextDay, totalGap,
totalGap, isLinkedToLoaded,
isLinkedToLoaded, }: RundownEntryProps) {
} = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions(); const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection(); const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
@@ -167,7 +166,7 @@ export default function RundownEntry(props: RundownEntryProps) {
if (isOntimeEvent(data)) { if (isOntimeEvent(data)) {
return ( return (
<EventBlock <RundownEvent
eventId={data.id} eventId={data.id}
eventIndex={eventIndex} eventIndex={eventIndex}
cue={data.cue} cue={data.cue}
@@ -201,7 +200,7 @@ export default function RundownEntry(props: RundownEntryProps) {
/> />
); );
} else if (isOntimeDelay(data)) { } else if (isOntimeDelay(data)) {
return <DelayBlock data={data} hasCursor={hasCursor} />; return <RundownDelay data={data} hasCursor={hasCursor} />;
} }
return null; return null;
} }
@@ -10,7 +10,7 @@ import { useAppMode } from '../../common/stores/appModeStore';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import RundownEventEditor from './event-editor/RundownEventEditor'; import RundownEntryEditor from './entry-editor/RundownEntryEditor';
import FinderPlacement from './placements/FinderPlacement'; import FinderPlacement from './placements/FinderPlacement';
import RundownWrapper from './RundownWrapper'; import RundownWrapper from './RundownWrapper';
@@ -63,7 +63,7 @@ function RundownExport() {
{!hideSideBar && ( {!hideSideBar && (
<div className={style.side}> <div className={style.side}>
<ErrorBoundary> <ErrorBoundary>
<RundownEventEditor /> <RundownEntryEditor />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
)} )}
@@ -21,7 +21,7 @@ describe('makeRundownMetadata()', () => {
block: { block: {
id: 'block', id: 'block',
type: SupportedEntry.Block, type: SupportedEntry.Block,
events: ['11', 'delay', '12', '13'], entries: ['11', 'delay', '12', '13'],
colour: 'red', colour: 'red',
} as OntimeBlock, } as OntimeBlock,
'11': { '11': {
@@ -209,7 +209,7 @@ describe('makeRundownMetadata()', () => {
id: 'block', id: 'block',
type: SupportedEntry.Block, type: SupportedEntry.Block,
colour: 'red', colour: 'red',
events: ['1', '2'], entries: ['1', '2'],
} as OntimeBlock, } as OntimeBlock,
'1': { '1': {
id: '1', id: '1',
@@ -288,12 +288,12 @@ describe('makeSortableList()', () => {
it('generates a list with block ends', () => { it('generates a list with block ends', () => {
const order = ['block-1', '2', 'block-3', 'block-4']; const order = ['block-1', '2', 'block-3', 'block-4'];
const entries: RundownEntries = { const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: ['11'] } as OntimeBlock, 'block-1': { type: SupportedEntry.Block, id: 'block-1', entries: ['11'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent, '11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
'2': { type: SupportedEntry.Event, id: '2', parent: null } as OntimeEvent, '2': { type: SupportedEntry.Event, id: '2', parent: null } as OntimeEvent,
'block-3': { type: SupportedEntry.Block, id: 'block-3', events: ['31'] } as OntimeBlock, 'block-3': { type: SupportedEntry.Block, id: 'block-3', entries: ['31'] } as OntimeBlock,
'31': { type: SupportedEntry.Event, id: '31', parent: 'block-3' } as OntimeEvent, '31': { type: SupportedEntry.Event, id: '31', parent: 'block-3' } as OntimeEvent,
'block-4': { type: SupportedEntry.Block, id: 'block-4', events: [] as string[] } as OntimeBlock, 'block-4': { type: SupportedEntry.Block, id: 'block-4', entries: [] as string[] } as OntimeBlock,
}; };
const sortableList = makeSortableList(order, entries); const sortableList = makeSortableList(order, entries);
@@ -313,7 +313,7 @@ describe('makeSortableList()', () => {
it('closes dangling blocks', () => { it('closes dangling blocks', () => {
const order = ['block']; const order = ['block'];
const entries: RundownEntries = { const entries: RundownEntries = {
block: { type: SupportedEntry.Block, id: 'block-1', events: ['11', '12'] } as OntimeBlock, block: { type: SupportedEntry.Block, id: 'block-1', entries: ['11', '12'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent, '11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
'12': { type: SupportedEntry.Event, id: '12', parent: 'block-1' } as OntimeEvent, '12': { type: SupportedEntry.Event, id: '12', parent: 'block-1' } as OntimeEvent,
}; };
@@ -325,8 +325,8 @@ describe('makeSortableList()', () => {
it('handles a list with a with just blocks', () => { it('handles a list with a with just blocks', () => {
const order = ['block-1', 'block-2']; const order = ['block-1', 'block-2'];
const entries: RundownEntries = { const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: [] as string[] } as OntimeBlock, 'block-1': { type: SupportedEntry.Block, id: 'block-1', entries: [] as string[] } as OntimeBlock,
'block-2': { type: SupportedEntry.Block, id: 'block-2', events: [] as string[] } as OntimeBlock, 'block-2': { type: SupportedEntry.Block, id: 'block-2', entries: [] as string[] } as OntimeBlock,
}; };
const sortableList = makeSortableList(order, entries); const sortableList = makeSortableList(order, entries);
@@ -338,62 +338,62 @@ describe('moveUp()', () => {
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3']; const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
const entries = { const entries = {
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent, event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent, event2: { type: 'event', id: 'event2', parent: null } as OntimeEvent,
block1: { type: 'block', id: 'block1', events: ['event3'] } as OntimeBlock, block1: { type: 'block', id: 'block1', entries: ['event3'] } as OntimeBlock,
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent, event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock, block2: { type: 'block', id: 'block2', entries: [] as EntryId[] } as OntimeBlock,
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent, event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
}; };
it('moves an event up in the list', () => { it('moves an event up in the list', () => {
const result = moveUp('event2', sortableData, entries); const result = moveUp('event2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event1', order: 'before', isBlock: false }); expect(result).toStrictEqual({ destinationId: 'event1', order: 'before', isBlock: false });
}) });
it.todo('disallows nesting blocks', () => { it.todo('disallows nesting blocks', () => {
const result = moveUp('block2', sortableData, entries); const result = moveUp('block2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block1', order: 'before', isBlock: false }); expect(result).toStrictEqual({ destinationId: 'block1', order: 'before', isBlock: false });
}) });
it('moves an event into a block', () => { it('moves an event into a block', () => {
const result = moveUp('event3', sortableData, entries); const result = moveUp('event3', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block2', order: 'insert', isBlock: true }); expect(result).toStrictEqual({ destinationId: 'block2', order: 'insert', isBlock: true });
}) });
it('moving up from top is noop', () => { it('moving up from top is noop', () => {
const result = moveUp('event1', sortableData, entries); const result = moveUp('event1', sortableData, entries);
expect(result).toMatchObject({ destinationId: null }); expect(result).toMatchObject({ destinationId: null });
}) });
}); });
describe('moveDown()', () => { describe('moveDown()', () => {
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3']; const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
const entries = { const entries = {
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent, event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent, event2: { type: 'event', id: 'event2', parent: null } as OntimeEvent,
block1: { type: 'block', id: 'block1', events: ['event11'] } as OntimeBlock, block1: { type: 'block', id: 'block1', entries: ['event11'] } as OntimeBlock,
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent, event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock, block2: { type: 'block', id: 'block2', entries: [] as EntryId[] } as OntimeBlock,
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent, event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
}; };
it('moves an event down in the list', () => { it('moves an event down in the list', () => {
const result = moveDown('event1', sortableData, entries); const result = moveDown('event1', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event2', order: 'after', isBlock: false }); expect(result).toStrictEqual({ destinationId: 'event2', order: 'after', isBlock: false });
}) });
it.todo('disallows nesting blocks', () => { it.todo('disallows nesting blocks', () => {
const result = moveDown('block1', sortableData, entries); const result = moveDown('block1', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block2', order: 'before', isBlock: false }); expect(result).toStrictEqual({ destinationId: 'block2', order: 'before', isBlock: false });
}) });
it('moves an event into a block', () => { it('moves an event into a block', () => {
const result = moveDown('event2', sortableData, entries); const result = moveDown('event2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event11', order: 'before', isBlock: true }); expect(result).toStrictEqual({ destinationId: 'event11', order: 'before', isBlock: true });
}) });
it('moving down from bottom is noop', () => { it('moving down from bottom is noop', () => {
const result = moveDown('event3', sortableData, entries); const result = moveDown('event3', sortableData, entries);
expect(result).toMatchObject({ destinationId: null }); expect(result).toMatchObject({ destinationId: null });
}) });
}); });
@@ -0,0 +1,147 @@
import { useCallback } from 'react';
import { OntimeBlock } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import NullableTimeInput from '../../../common/components/input/time-input/NullableTimeInput';
import AppLink from '../../../common/components/link/app-link/AppLink';
import Switch from '../../../common/components/switch/Switch';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import TextLikeInput from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput';
import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EntryEditorTextInput from './composite/EventTextInput';
import style from './EntryEditor.module.scss';
// title + colour + custom field labels
export type BlockEditorUpdateTextFields = 'targetDuration' | 'title' | 'colour' | string;
export type BlockEditorUpdateMaybeNumberFields = 'targetDuration';
export type BlockEditorBooleanFields = 'isNextDay';
interface BlockEditorProps {
block: OntimeBlock;
}
export default function BlockEditor({ block }: BlockEditorProps) {
const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActions();
const handleSubmit = useCallback(
(
field: BlockEditorUpdateTextFields | BlockEditorUpdateMaybeNumberFields | BlockEditorBooleanFields,
value: string | boolean,
) => {
// Handle custom fields
if (typeof field === 'string' && field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
updateEntry({ id: block.id, custom: { [fieldLabel]: value as string } });
return;
}
if (field === 'targetDuration') {
if (value === '') {
return updateEntry({ id: block.id, targetDuration: null });
}
return updateEntry({ id: block.id, targetDuration: parseUserTime(value as string) });
}
if (field === 'isNextDay') {
return updateEntry({ id: block.id, isNextDay: value as boolean });
}
// all other strings are text fields
return updateEntry({ id: block.id, [field]: value as string });
},
[block.id, updateEntry],
);
const isEditor = window.location.pathname.includes('editor');
const planOffset = typeof block.targetDuration !== 'number' ? null : block.targetDuration - block.duration;
console.log('targetDuration:', block.targetDuration);
return (
<div className={style.content}>
<div className={style.column}>
<Editor.Title>Block schedule</Editor.Title>
<div className={style.inline}>
<div>
{
// TODO: format with user time settings
}
<Editor.Label>First event start</Editor.Label>
<TextLikeInput className={style.textLikeInput}>
{millisToString(block.startTime, { fallback: timerPlaceholder })}
</TextLikeInput>
</div>
<div>
<Editor.Label htmlFor='endTime'>Last event end</Editor.Label>
<TextLikeInput className={style.textLikeInput}>
{millisToString(block.endTime, { fallback: timerPlaceholder })}
</TextLikeInput>
</div>
<div>
<Editor.Label htmlFor='duration'>Scheduled duration</Editor.Label>
<TextLikeInput className={style.textLikeInput}>
{millisToString(block.duration, { fallback: enDash })}
</TextLikeInput>
</div>
</div>
<div className={style.inline}>
<div>
<Editor.Label htmlFor='targetDuration'>Target duration</Editor.Label>
<NullableTimeInput
name='targetDuration'
time={block.targetDuration}
submitHandler={handleSubmit}
emptyDisplay={enDash}
/>
</div>
<div>
<Editor.Label htmlFor='eventId'>Plan offset</Editor.Label>
{
// TODO: update remote data
// TODO: remove tab index
}
<TextLikeInput delayed={Boolean(planOffset)} className={style.textLikeInput}>
{millisToString(planOffset, { fallback: enDash })}
</TextLikeInput>
</div>
</div>
<div>
<Editor.Label htmlFor='isNextDay'>Is next day?</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
checked={block.isNextDay}
onCheckedChange={(checked) => {
handleSubmit('isNextDay', checked);
}}
/>
{block.isNextDay ? 'Events start the day after' : '-'}
</Editor.Label>
</div>
</div>
<div className={style.column}>
<Editor.Title>Block data</Editor.Title>
<div>
<Editor.Label>Colour</Editor.Label>
<SwatchSelect name='colour' value={block.colour} handleChange={handleSubmit} />
</div>
<EntryEditorTextInput field='title' label='Title' initialValue={block.title} submitHandler={handleSubmit} />
</div>
<div className={style.column}>
<Editor.Title>
Custom Fields
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} event={block} />
</div>
</div>
);
}
@@ -6,14 +6,13 @@ import { cx } from '../../../common/utils/styleUtils';
import EventEditor from './EventEditor'; import EventEditor from './EventEditor';
import style from './EventEditor.module.scss'; import style from './EntryEditor.module.scss';
interface CuesheetEventEditorProps { interface CuesheetEventEditorProps {
eventId: string; eventId: string;
} }
export default function CuesheetEventEditor(props: CuesheetEventEditorProps) { export default function CuesheetEventEditor({ eventId }: CuesheetEventEditorProps) {
const { eventId } = props;
const { data } = useRundown(); const { data } = useRundown();
const [event, setEvent] = useState<OntimeEvent | null>(null); const [event, setEvent] = useState<OntimeEvent | null>(null);
@@ -37,7 +36,7 @@ export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
} }
return ( return (
<div className={cx([style.eventEditor, style.inModal])} data-testid='editor-container'> <div className={cx([style.entryEditor, style.inModal])} data-testid='editor-container'>
<EventEditor event={event} /> <EventEditor event={event} />
</div> </div>
); );
@@ -1,4 +1,4 @@
.eventEditor { .entryEditor {
max-height: 100%; max-height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -88,3 +88,19 @@
grid-template-columns: 1fr 72px; grid-template-columns: 1fr 72px;
gap: 1rem; gap: 1rem;
} }
/* approximating the style of a disabled input */
.textLikeInput {
background-color: rgba($gray-1200, 0.4);
font-weight: 400;
color: $gray-200;
border: 1px solid transparent;
justify-content: center;
width: 7.5em;
&:hover {
background-color: rgba($gray-1200, 0.4);
}
}
@@ -6,44 +6,38 @@ import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import EventCustom from './composite/EventEditorCustom'; import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EventEditorTimes from './composite/EventEditorTimes'; import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles'; import EventEditorTitles from './composite/EventEditorTitles';
import EventEditorTriggers from './composite/EventEditorTriggers'; import EventEditorTriggers from './composite/EventEditorTriggers';
import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss'; import style from './EntryEditor.module.scss';
// any of the titles + custom field labels // any of the titles + colour + custom field labels
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string; export type EventEditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
interface EventEditorProps { interface EventEditorProps {
event: OntimeEvent; event: OntimeEvent;
} }
export default function EventEditor(props: EventEditorProps) { export default function EventEditor({ event }: EventEditorProps) {
const { event } = props;
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActions();
const isEditor = window.location.pathname.includes('editor'); const isEditor = window.location.pathname.includes('editor');
const handleSubmit = useCallback( const handleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => { (field: EventEditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) { if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1]; const fieldLabel = field.split('custom-')[1];
updateEntry({ id: event?.id, custom: { [fieldLabel]: value } }); updateEntry({ id: event.id, custom: { [fieldLabel]: value } });
} else { } else {
updateEntry({ id: event?.id, [field]: value }); updateEntry({ id: event.id, [field]: value });
} }
}, },
[event?.id, updateEntry], [event.id, updateEntry],
); );
if (!event) {
return <EventEditorEmpty />;
}
return ( return (
<div className={style.content}> <div className={style.content}>
<EventEditorTimes <EventEditorTimes
@@ -55,7 +49,7 @@ export default function EventEditor(props: EventEditorProps) {
timeStrategy={event.timeStrategy} timeStrategy={event.timeStrategy}
linkStart={event.linkStart} linkStart={event.linkStart}
countToEnd={event.countToEnd} countToEnd={event.countToEnd}
delay={event.delay ?? 0} delay={event.delay}
endAction={event.endAction} endAction={event.endAction}
timerType={event.timerType} timerType={event.timerType}
timeWarning={event.timeWarning} timeWarning={event.timeWarning}
@@ -75,7 +69,7 @@ export default function EventEditor(props: EventEditorProps) {
Custom Fields Custom Fields
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>} {isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
</Editor.Title> </Editor.Title>
<EventCustom fields={customFields} handleSubmit={handleSubmit} event={event} /> <EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} event={event} />
</div> </div>
<div className={style.column}> <div className={style.column}>
<Editor.Title> <Editor.Title>
@@ -1,4 +1,4 @@
.eventEditor { .entryEditor {
color: $label-gray; color: $label-gray;
height: 100%; height: 100%;
max-height: 100%; max-height: 100%;
@@ -46,3 +46,15 @@
text-align: center; text-align: center;
width: 1em; width: 1em;
} }
.kbd {
font-family: monospace;
white-space: nowrap;
font-size: calc(1rem - 2px);
padding: 0.125rem 0.5rem;
background-color: $gray-1200;
color: $ui-white;
border-radius: 2px;
font-weight: 400;
box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.4);
}
@@ -1,5 +1,4 @@
import { memo, PropsWithChildren } from 'react'; import { memo, PropsWithChildren } from 'react';
import { Kbd } from '@chakra-ui/react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
@@ -10,7 +9,7 @@ export default memo(EventEditorEmpty);
function EventEditorEmpty() { function EventEditorEmpty() {
return ( return (
<div className={style.eventEditor} data-testid='editor-container'> <div className={style.entryEditor} data-testid='editor-container'>
<div className={style.shortcutSection}> <div className={style.shortcutSection}>
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title> <Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
<table className={style.shortcuts}> <table className={style.shortcuts}>
@@ -172,3 +171,7 @@ function EventEditorEmpty() {
function AuxKey({ children }: PropsWithChildren) { function AuxKey({ children }: PropsWithChildren) {
return <span className={style.divider}>{children}</span>; return <span className={style.divider}>{children}</span>;
} }
function Kbd({ children }: PropsWithChildren) {
return <span className={style.kbd}>{children}</span>;
}
@@ -1,20 +1,21 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { isOntimeEvent, OntimeEvent } from 'ontime-types'; import { isOntimeBlock, isOntimeDelay, OntimeBlock, OntimeEvent } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown'; import useRundown from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
import { EventEditorFooter } from './composite/EventEditorFooter'; import EventEditorFooter from './composite/EventEditorFooter';
import BlockEditor from './BlockEditor';
import EventEditor from './EventEditor'; import EventEditor from './EventEditor';
import EventEditorEmpty from './EventEditorEmpty'; import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss'; import style from './EntryEditor.module.scss';
export default function RundownEventEditor() { export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown(); const { data } = useRundown();
const [event, setEvent] = useState<OntimeEvent | null>(null); const [event, setEvent] = useState<OntimeEvent | OntimeBlock | null>(null);
useEffect(() => { useEffect(() => {
if (data.order.length === 0) { if (data.order.length === 0) {
@@ -29,7 +30,7 @@ export default function RundownEventEditor() {
} }
const event = data.entries[selectedEventId]; const event = data.entries[selectedEventId];
if (event && isOntimeEvent(event)) { if (event && !isOntimeDelay(event)) {
setEvent(event); setEvent(event);
} else { } else {
setEvent(null); setEvent(null);
@@ -40,8 +41,16 @@ export default function RundownEventEditor() {
return <EventEditorEmpty />; return <EventEditorEmpty />;
} }
if (isOntimeBlock(event)) {
return (
<div className={style.entryEditor} data-testid='editor-container'>
<BlockEditor block={event} />
</div>
);
}
return ( return (
<div className={style.eventEditor} data-testid='editor-container'> <div className={style.entryEditor} data-testid='editor-container'>
<EventEditor event={event} /> <EventEditor event={event} />
<EventEditorFooter id={event.id} cue={event.cue} /> <EventEditorFooter id={event.id} cue={event.cue} />
</div> </div>
@@ -1,23 +1,26 @@
import { CSSProperties, Fragment } from 'react'; import { CSSProperties, Fragment } from 'react';
import { CustomFields, OntimeEvent } from 'ontime-types'; import { CustomFields, OntimeBlock, OntimeEvent } from 'ontime-types';
import { getAccessibleColour } from '../../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { EditorUpdateFields } from '../EventEditor'; import { EventEditorUpdateFields } from '../EventEditor';
import EventEditorImage from './EventEditorImage'; import EventEditorImage from './EventEditorImage';
import EventTextArea from './EventTextArea'; import EventTextArea from './EventTextArea';
import EventTextInput from './EventTextInput'; import EntryEditorTextInput from './EventTextInput';
import style from '../EventEditor.module.scss'; import style from '../EntryEditor.module.scss';
interface EventEditorCustomProps { interface EntryEditorCustomFieldsProps {
fields: CustomFields; fields: CustomFields;
event: OntimeEvent; event: OntimeEvent | OntimeBlock;
handleSubmit: (field: EditorUpdateFields, value: string) => void; handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
} }
export default function EventEditorCustom(props: EventEditorCustomProps) { export default function EntryEditorCustomFields({
const { fields: customFields, handleSubmit, event } = props; fields: customFields,
handleSubmit,
event,
}: EntryEditorCustomFieldsProps) {
return ( return (
<Fragment> <Fragment>
{Object.keys(customFields).map((fieldKey) => { {Object.keys(customFields).map((fieldKey) => {
@@ -44,7 +47,7 @@ export default function EventEditorCustom(props: EventEditorCustomProps) {
if (customFields[fieldKey].type === 'image') { if (customFields[fieldKey].type === 'image') {
return ( return (
<div key={key} className={style.customImage}> <div key={key} className={style.customImage}>
<EventTextInput <EntryEditorTextInput
key={key} key={key}
field={fieldName} field={fieldName}
label={labelText} label={labelText}
@@ -9,11 +9,8 @@ interface EventEditorFooterProps {
cue: string; cue: string;
} }
export const EventEditorFooter = memo(_EventEditorFooter); export default memo(EventEditorFooter);
function EventEditorFooter({ id, cue }: EventEditorFooterProps) {
function _EventEditorFooter(props: EventEditorFooterProps) {
const { id, cue } = props;
const loadById = `/ontime/load/id "${id}"`; const loadById = `/ontime/load/id "${id}"`;
const loadByCue = `/ontime/load/cue "${cue}"`; const loadByCue = `/ontime/load/cue "${cue}"`;
@@ -4,9 +4,7 @@ interface EventEditorImageProps {
src: string; src: string;
} }
export default function EventEditorImage(props: EventEditorImageProps) { export default function EventEditorImage({ src }: EventEditorImageProps) {
const { src } = props;
return ( return (
<div className={style.imageContainer}> <div className={style.imageContainer}>
<img loading='lazy' src={src} /> <img loading='lazy' src={src} />
@@ -1,16 +1,18 @@
import { memo } from 'react'; import { memo } from 'react';
import { IoInformationCircle } from 'react-icons/io5'; import { IoInformationCircle } from 'react-icons/io5';
import { Select, Switch, Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { EndAction, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig'; import { millisToDelayString } from '../../../../common/utils/dateConfig';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EventEditor.module.scss'; import style from '../EntryEditor.module.scss';
interface EventEditorTimesProps { interface EventEditorTimesProps {
eventId: string; eventId: string;
@@ -30,26 +32,25 @@ interface EventEditorTimesProps {
type HandledActions = 'countToEnd' | 'timerType' | 'endAction' | 'timeWarning' | 'timeDanger'; type HandledActions = 'countToEnd' | 'timerType' | 'endAction' | 'timeWarning' | 'timeDanger';
export default memo(EventEditorTimes); export default memo(EventEditorTimes);
function EventEditorTimes(props: EventEditorTimesProps) { function EventEditorTimes({
const { eventId,
eventId, timeStart,
timeStart, timeEnd,
timeEnd, duration,
duration, timeStrategy,
timeStrategy, linkStart,
linkStart, countToEnd,
countToEnd, delay,
delay, endAction,
endAction, timerType,
timerType, timeWarning,
timeWarning, timeDanger,
timeDanger, }: EventEditorTimesProps) {
} = props;
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActions();
const handleSubmit = (field: HandledActions, value: string | boolean) => { const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'countToEnd') { if (field === 'countToEnd') {
updateEntry({ id: eventId, countToEnd: !(value as boolean) }); updateEntry({ id: eventId, countToEnd: value as boolean });
return; return;
} }
@@ -100,27 +101,22 @@ function EventEditorTimes(props: EventEditorTimesProps) {
<div> <div>
<Editor.Label htmlFor='endAction'>End Action</Editor.Label> <Editor.Label htmlFor='endAction'>End Action</Editor.Label>
<Select <Select
id='endAction'
size='sm'
name='endAction'
value={endAction} value={endAction}
onChange={(event) => handleSubmit('endAction', event.target.value)} onChange={(value) => handleSubmit('endAction', value)}
variant='ontime' options={[
> { value: EndAction.None, label: 'None' },
<option value={EndAction.None}>None</option> { value: EndAction.LoadNext, label: 'Load next event' },
<option value={EndAction.LoadNext}>Load next event</option> { value: EndAction.PlayNext, label: 'Play next event' },
<option value={EndAction.PlayNext}>Play next event</option> ]}
</Select> />
</div> </div>
<div> <div>
<Editor.Label htmlFor='countToEnd'>Count to End</Editor.Label> <Editor.Label htmlFor='countToEnd'>Count to End</Editor.Label>
<Editor.Label className={style.switchLabel}> <Editor.Label className={style.switchLabel}>
<Switch <Switch
id='countToEnd' id='countToEnd'
size='md' checked={countToEnd}
isChecked={countToEnd} onCheckedChange={(value) => handleSubmit('countToEnd', value)}
onChange={() => handleSubmit('countToEnd', countToEnd)}
variant='ontime'
/> />
{countToEnd ? 'On' : 'Off'} {countToEnd ? 'On' : 'Off'}
</Editor.Label> </Editor.Label>
@@ -141,18 +137,15 @@ function EventEditorTimes(props: EventEditorTimesProps) {
<div> <div>
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label> <Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
<Select <Select
size='sm'
id='timerType'
name='timerType'
value={timerType} value={timerType}
onChange={(event) => handleSubmit('timerType', event.target.value)} onChange={(value) => handleSubmit('timerType', value)}
variant='ontime' options={[
> { value: TimerType.CountDown, label: 'Count down' },
<option value={TimerType.CountDown}>Count down</option> { value: TimerType.CountUp, label: 'Count up' },
<option value={TimerType.CountUp}>Count up</option> { value: TimerType.Clock, label: 'Clock' },
<option value={TimerType.Clock}>Clock</option> { value: TimerType.None, label: 'None' },
<option value={TimerType.None}>None</option> ]}
</Select> />
</div> </div>
<div className={style.inline}> <div className={style.inline}>
@@ -1,15 +1,15 @@
import { memo } from 'react'; import { memo } from 'react';
import { Input } from '@chakra-ui/react';
import { sanitiseCue } from 'ontime-utils'; import { sanitiseCue } from 'ontime-utils';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import { type EditorUpdateFields } from '../EventEditor'; import Input from '../../../../common/components/input/input/Input';
import { type EventEditorUpdateFields } from '../EventEditor';
import EventTextArea from './EventTextArea'; import EventTextArea from './EventTextArea';
import EventTextInput from './EventTextInput'; import EntryEditorTextInput from './EventTextInput';
import style from '../EventEditor.module.scss'; import style from '../EntryEditor.module.scss';
interface EventEditorTitlesProps { interface EventEditorTitlesProps {
eventId: string; eventId: string;
@@ -17,12 +17,11 @@ interface EventEditorTitlesProps {
title: string; title: string;
note: string; note: string;
colour: string; colour: string;
handleSubmit: (field: EditorUpdateFields, value: string) => void; handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
} }
const EventEditorTitles = (props: EventEditorTitlesProps) => { export default memo(EventEditorTitles);
const { eventId, cue, title, note, colour, handleSubmit } = props; function EventEditorTitles({ eventId, cue, title, note, colour, handleSubmit }: EventEditorTitlesProps) {
const cueSubmitHandler = (_field: string, newValue: string) => { const cueSubmitHandler = (_field: string, newValue: string) => {
handleSubmit('cue', sanitiseCue(newValue)); handleSubmit('cue', sanitiseCue(newValue));
}; };
@@ -33,25 +32,22 @@ const EventEditorTitles = (props: EventEditorTitlesProps) => {
<div className={style.splitTwo}> <div className={style.splitTwo}>
<div> <div>
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label> <Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
<Input <Input id='eventId' data-testid='input-textfield' value={eventId} readOnly fluid />
id='eventId'
size='sm'
variant='ontime-filled'
data-testid='input-textfield'
value={eventId}
readOnly
/>
</div> </div>
<EventTextInput field='cue' label='Cue' initialValue={cue} submitHandler={cueSubmitHandler} maxLength={10} /> <EntryEditorTextInput
field='cue'
label='Cue'
initialValue={cue}
submitHandler={cueSubmitHandler}
maxLength={10}
/>
</div> </div>
<div> <div>
<Editor.Label>Colour</Editor.Label> <Editor.Label>Colour</Editor.Label>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} /> <SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div> </div>
<EventTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} /> <EntryEditorTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<EventTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} /> <EventTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
</div> </div>
); );
}; }
export default memo(EventEditorTitles);
@@ -1,9 +1,12 @@
import { Fragment, useCallback, useState } from 'react'; import { Fragment, useCallback, useState } from 'react';
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5'; import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Select, Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types'; import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
import { generateId } from 'ontime-utils'; import { generateId } from 'ontime-utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
@@ -17,8 +20,7 @@ interface EventEditorTriggersProps {
triggers: Trigger[]; triggers: Trigger[];
} }
export default function EventEditorTriggers(props: EventEditorTriggersProps) { export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
const { triggers, eventId } = props;
const showTriggers = triggers.length > 0; const showTriggers = triggers.length > 0;
return ( return (
@@ -34,8 +36,7 @@ interface EventTriggerFormProps {
triggers?: Trigger[]; triggers?: Trigger[];
} }
function EventTriggerForm(props: EventTriggerFormProps) { function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
const { eventId, triggers } = props;
const { data: automationSettings } = useAutomationSettings(); const { data: automationSettings } = useAutomationSettings();
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActions();
const [automationId, setAutomationId] = useState<string | undefined>(undefined); const [automationId, setAutomationId] = useState<string | undefined>(undefined);
@@ -68,38 +69,21 @@ function EventTriggerForm(props: EventTriggerFormProps) {
return ( return (
<div className={style.triggerForm}> <div className={style.triggerForm}>
<Select <Select
size='sm'
variant='ontime'
value={cycleValue} value={cycleValue}
onChange={(e) => setCycleValue(e.target.value as TimerLifeCycle)} placeholder='Choose a trigger'
> onChange={(value) => setCycleValue(value)}
<option disabled>Lifecycle Trigger</option> options={eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }))}
{eventTriggerOptions.map((cycle) => ( />
<option key={cycle} value={cycle}>
{cycle}
</option>
))}
</Select>
<Select <Select
size='sm'
variant='ontime'
value={automationId} value={automationId}
defaultValue='«invalid»' placeholder='Choose an automation'
onChange={(e) => setAutomationId(e.target.value)} onChange={(value) => setAutomationId(value)}
> options={Object.values(automationSettings.automations).map(({ id, title }) => ({ value: id, label: title }))}
<option disabled value='«invalid»'> />
Automation
</option>
{Object.values(automationSettings.automations).map(({ id, title }) => (
<option key={id} value={id}>
{title}
</option>
))}
</Select>
<Button <Button
variant='ontime-subtle' disabled={validationError !== undefined}
size='sm'
isDisabled={validationError !== undefined}
onClick={() => automationId && handleSubmit(cycleValue, automationId)} onClick={() => automationId && handleSubmit(cycleValue, automationId)}
> >
Add Add
@@ -120,8 +104,7 @@ interface ExistingEventTriggersProps {
triggers: Trigger[]; triggers: Trigger[];
} }
function ExistingEventTriggers(props: ExistingEventTriggersProps) { function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) {
const { eventId, triggers } = props;
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActions();
const { data: automationSettings } = useAutomationSettings(); const { data: automationSettings } = useAutomationSettings();
@@ -154,14 +137,9 @@ function ExistingEventTriggers(props: ExistingEventTriggersProps) {
<div key={id} className={style.trigger}> <div key={id} className={style.trigger}>
<Tag>{triggerLifeCycle}</Tag> <Tag>{triggerLifeCycle}</Tag>
<Tag>{automationTitle}</Tag> <Tag>{automationTitle}</Tag>
<IconButton <IconButton variant='subtle-destructive' onClick={() => handleDelete(id)}>
size='sm' <IoTrash />
variant='ontime-ghosted' </IconButton>
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDelete(id)}
/>
</div> </div>
); );
})} })}
@@ -3,19 +3,25 @@ import { type CSSProperties, useCallback, useRef } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea'; import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { EditorUpdateFields } from '../EventEditor'; import { EventEditorUpdateFields } from '../EventEditor';
interface CountedTextAreaProps { interface CountedTextAreaProps {
className?: string; className?: string;
field: EditorUpdateFields; field: EventEditorUpdateFields;
label: string; label: string;
initialValue: string; initialValue: string;
style?: CSSProperties; style?: CSSProperties;
submitHandler: (field: EditorUpdateFields, value: string) => void; submitHandler: (field: EventEditorUpdateFields, value: string) => void;
} }
export default function EventTextArea(props: CountedTextAreaProps) { export default function EventTextArea({
const { className, field, label, initialValue, style: givenStyles, submitHandler } = props; className,
field,
label,
initialValue,
style: givenStyles,
submitHandler,
}: CountedTextAreaProps) {
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]); const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
@@ -1,20 +1,29 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { Input, InputProps } from '@chakra-ui/react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import Input, { type InputProps } from '../../../../common/components/input/input/Input';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { EditorUpdateFields } from '../EventEditor'; import { BlockEditorUpdateTextFields } from '../BlockEditor';
import { EventEditorUpdateFields } from '../EventEditor';
interface EventTextInputProps extends InputProps { interface EntryEditorTextInputProps extends InputProps {
field: EditorUpdateFields; field: EventEditorUpdateFields | BlockEditorUpdateTextFields;
label: string; label: string;
initialValue: string; initialValue: string;
placeholder?: string; placeholder?: string;
submitHandler: (field: EditorUpdateFields, value: string) => void; submitHandler: (field: EventEditorUpdateFields, value: string) => void;
} }
export default function EventTextInput(props: EventTextInputProps) { export default function EntryEditorTextInput({
const { className, field, label, initialValue, style: givenStyles, submitHandler, maxLength, placeholder } = props; className,
field,
label,
initialValue,
style: givenStyles,
submitHandler,
maxLength,
placeholder,
}: EntryEditorTextInputProps) {
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]); const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
@@ -30,16 +39,14 @@ export default function EventTextInput(props: EventTextInputProps) {
<Input <Input
id={field} id={field}
ref={ref} ref={ref}
size='sm' maxLength={maxLength}
variant='ontime-filled' fluid
data-testid='input-textfield' data-testid='input-textfield'
value={value} value={value}
maxLength={maxLength || 100}
placeholder={placeholder} placeholder={placeholder}
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
autoComplete='off'
/> />
</div> </div>
); );
@@ -5,5 +5,5 @@
padding-block: 0.5rem; padding-block: 0.5rem;
padding-left: calc(2em + 0.5rem); padding-left: calc(2em + 0.5rem);
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%); background-color: color-mix(in srgb, var(--user-bg, transparent) 15%, transparent 85%);
} }
@@ -14,9 +14,7 @@ interface QuickAddBlockProps {
} }
export default memo(QuickAddBlock); export default memo(QuickAddBlock);
function QuickAddBlock({ previousEventId, parentBlock, backgroundColor }: QuickAddBlockProps) {
function QuickAddBlock(props: QuickAddBlockProps) {
const { previousEventId, parentBlock, backgroundColor } = props;
const { addEntry } = useEntryActions(); const { addEntry } = useEntryActions();
const doLinkPrevious = useRef<HTMLInputElement | null>(null); const doLinkPrevious = useRef<HTMLInputElement | null>(null);
@@ -2,7 +2,7 @@
.blockEnd { .blockEnd {
cursor: default; cursor: default;
height: 0.5rem; height: 1rem;
background-color: var(--user-bg, $gray-1050); background-color: var(--user-bg, $gray-1050);
border-radius: 0 0 $block-border-radius $block-border-radius; border-radius: 0 0 $block-border-radius $block-border-radius;
@@ -8,8 +8,7 @@ interface BlockEndProps {
colour?: string; colour?: string;
} }
export default function BlockEnd(props: BlockEndProps) { export default function BlockEnd({ id, colour }: BlockEndProps) {
const { id, colour } = props;
const { const {
attributes: dragAttributes, attributes: dragAttributes,
listeners: dragListeners, listeners: dragListeners,
@@ -37,6 +36,7 @@ export default function BlockEnd(props: BlockEndProps) {
...dragStyle, ...dragStyle,
...(colour ? { '--user-bg': colour } : {}), ...(colour ? { '--user-bg': colour } : {}),
}} }}
tabIndex={-1}
/> />
); );
} }
@@ -26,9 +26,11 @@
color: $section-white; color: $section-white;
font-size: 1rem; font-size: 1rem;
display: grid; display: grid;
justify-content: center; place-content: center;
padding-top: 0.25rem; position: relative;
} cursor: pointer;
}
.header { .header {
grid-area: header; grid-area: header;
@@ -63,6 +65,8 @@
.drag { .drag {
@include drag-style; @include drag-style;
position: absolute;
margin-top: 0.25rem;
&.isDragging { &.isDragging {
cursor: grabbing; cursor: grabbing;
@@ -1,4 +1,4 @@
import { useRef } from 'react'; import { MouseEvent, useRef } from 'react';
import { import {
IoChevronDown, IoChevronDown,
IoChevronUp, IoChevronUp,
@@ -18,20 +18,21 @@ import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time'; import { formatDuration, formatTime } from '../../../common/utils/time';
import EditableBlockTitle from '../common/EditableBlockTitle'; import EditableBlockTitle from '../common/EditableBlockTitle';
import { canDrop } from '../rundown.utils'; import { canDrop } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection';
import style from './BlockBlock.module.scss'; import style from './RundownBlock.module.scss';
interface BlockBlockProps { interface RundownBlockProps {
data: OntimeBlock; data: OntimeBlock;
hasCursor: boolean; hasCursor: boolean;
collapsed: boolean; collapsed: boolean;
onCollapse: (collapsed: boolean, groupId: EntryId) => void; onCollapse: (collapsed: boolean, groupId: EntryId) => void;
} }
export default function BlockBlock(props: BlockBlockProps) { export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }: RundownBlockProps) {
const { data, hasCursor, collapsed, onCollapse } = props;
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useEntryActions(); const { clone, ungroup, deleteEntry } = useEntryActions();
const { selectedEvents, setSelectedBlock } = useEventSelection();
const [onContextMenu] = useContextMenu<HTMLDivElement>([ const [onContextMenu] = useContextMenu<HTMLDivElement>([
{ {
@@ -43,7 +44,7 @@ export default function BlockBlock(props: BlockBlockProps) {
label: 'Ungroup', label: 'Ungroup',
icon: IoFolderOpenOutline, icon: IoFolderOpenOutline,
onClick: () => ungroup(data.id), onClick: () => ungroup(data.id),
isDisabled: data.events.length === 0, isDisabled: data.entries.length === 0,
}, },
{ {
label: 'Delete Block', label: 'Delete Block',
@@ -70,6 +71,20 @@ export default function BlockBlock(props: BlockBlockProps) {
animateLayoutChanges: () => false, animateLayoutChanges: () => false,
}); });
const handleFocusClick = (event: MouseEvent) => {
event.stopPropagation();
// event.button === 2 is a right-click
// disable selection if the user selected events and right clicks
// so the context menu shows up
if (selectedEvents.size > 1 && event.button === 2) {
return;
}
// UI indexes are 1 based
setSelectedBlock({ id: data.id });
};
const binderColours = data.colour && getAccessibleColour(data.colour); const binderColours = data.colour && getAccessibleColour(data.colour);
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent); const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
@@ -84,6 +99,7 @@ export default function BlockBlock(props: BlockBlockProps) {
<div <div
className={cx([style.block, hasCursor && style.hasCursor, !collapsed && style.expanded])} className={cx([style.block, hasCursor && style.hasCursor, !collapsed && style.expanded])}
ref={setNodeRef} ref={setNodeRef}
onClick={handleFocusClick}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
style={{ style={{
...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}), ...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
@@ -128,7 +144,7 @@ export default function BlockBlock(props: BlockBlockProps) {
</div> </div>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div>Events</div> <div>Events</div>
<div>{data.events.length}</div> <div>{data.entries.length}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -9,15 +9,14 @@ import DelayInput from '../../../common/components/input/delay-input/DelayInput'
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './DelayBlock.module.scss'; import style from './RundownDelay.module.scss';
interface DelayBlockProps { interface RundownDelayProps {
data: OntimeDelay; data: OntimeDelay;
hasCursor: boolean; hasCursor: boolean;
} }
export default function DelayBlock(props: DelayBlockProps) { export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
const { data, hasCursor } = props;
const { applyDelay, deleteEntry } = useEntryActions(); const { applyDelay, deleteEntry } = useEntryActions();
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -2,7 +2,7 @@
$skip-opacity: 0.2; $skip-opacity: 0.2;
.eventBlock { .rundownEvent {
@include block-styling; @include block-styling;
background-color: $block-bg; background-color: $block-bg;
margin-block: 0.25rem; margin-block: 0.25rem;
@@ -19,12 +19,12 @@ import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping'; import { useEventIdSwapping } from '../useEventIdSwapping';
import { getSelectionMode, useEventSelection } from '../useEventSelection'; import { getSelectionMode, useEventSelection } from '../useEventSelection';
import EventBlockInner from './EventBlockInner'; import RundownEventInner from './RundownEventInner';
import RundownIndicators from './RundownIndicators'; import RundownIndicators from './RundownIndicators';
import style from './EventBlock.module.scss'; import style from './RundownEvent.module.scss';
interface EventBlockProps { interface RundownEventProps {
eventId: EntryId; eventId: EntryId;
cue: string; cue: string;
timeStart: number; timeStart: number;
@@ -65,39 +65,38 @@ interface EventBlockProps {
hasTriggers: boolean; hasTriggers: boolean;
} }
export default function EventBlock(props: EventBlockProps) { export default function RundownEvent({
const { eventId,
eventId, cue,
cue, timeStart,
timeStart, timeEnd,
timeEnd, duration,
duration, timeStrategy,
timeStrategy, linkStart,
linkStart, countToEnd,
countToEnd, eventIndex,
eventIndex, endAction,
endAction, timerType,
timerType, title,
title, note,
note, delay,
delay, colour,
colour, isPast,
isPast, isNext,
isNext, skip = false,
skip = false, parent,
parent, loaded,
loaded, hasCursor,
hasCursor, playback,
playback, isRolling,
isRolling, gap,
gap, isNextDay,
isNextDay, dayOffset,
dayOffset, totalGap,
totalGap, isLinkedToLoaded,
isLinkedToLoaded, actionHandler,
actionHandler, hasTriggers,
hasTriggers, }: RundownEventProps) {
} = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const { selectedEvents, setSelectedEvents } = useEventSelection(); const { selectedEvents, setSelectedEvents } = useEventSelection();
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -224,7 +223,7 @@ export default function EventBlock(props: EventBlockProps) {
const isSelected = selectedEvents.has(eventId); const isSelected = selectedEvents.has(eventId);
const blockClasses = cx([ const blockClasses = cx([
style.eventBlock, style.rundownEvent,
skip ? style.skip : null, skip ? style.skip : null,
isPast ? style.past : null, isPast ? style.past : null,
loaded ? style.loaded : null, loaded ? style.loaded : null,
@@ -268,7 +267,7 @@ export default function EventBlock(props: EventBlockProps) {
</div> </div>
{isVisible && ( {isVisible && (
<EventBlockInner <RundownEventInner
timeStart={timeStart} timeStart={timeStart}
timeEnd={timeEnd} timeEnd={timeEnd}
duration={duration} duration={duration}
@@ -18,13 +18,13 @@ import { tooltipDelayMid } from '../../../ontimeConfig';
import EditableBlockTitle from '../common/EditableBlockTitle'; import EditableBlockTitle from '../common/EditableBlockTitle';
import TimeInputFlow from '../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../time-input-flow/TimeInputFlow';
import EventBlockChip from './composite/EventBlockChip'; import RundownEventChip from './composite/RundownEventChip';
import EventBlockPlayback from './composite/EventBlockPlayback'; import EventBlockPlayback from './composite/RundownEventPlayback';
import EventBlockProgressBar from './composite/EventBlockProgressBar'; import EventBlockProgressBar from './composite/RundownEventProgressBar';
import style from './EventBlock.module.scss'; import style from './RundownEvent.module.scss';
interface EventBlockInnerProps { interface RundownEventInnerProps {
eventId: string; eventId: string;
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
@@ -50,32 +50,31 @@ interface EventBlockInnerProps {
hasTriggers: boolean; hasTriggers: boolean;
} }
function EventBlockInner(props: EventBlockInnerProps) { export default memo(RundownEventInner);
const { function RundownEventInner({
eventId, eventId,
timeStart, timeStart,
timeEnd, timeEnd,
duration, duration,
timeStrategy, timeStrategy,
linkStart, linkStart,
countToEnd, countToEnd,
endAction, endAction,
timerType, timerType,
title, title,
note, note,
delay, delay,
isNext, isNext,
skip = false, skip = false,
loaded, loaded,
playback, playback,
isRolling, isRolling,
dayOffset, dayOffset,
isPast, isPast,
totalGap, totalGap,
isLinkedToLoaded, isLinkedToLoaded,
hasTriggers, hasTriggers,
} = props; }: RundownEventInnerProps) {
const [renderInner, setRenderInner] = useState(false); const [renderInner, setRenderInner] = useState(false);
useEffect(() => { useEffect(() => {
@@ -119,7 +118,7 @@ function EventBlockInner(props: EventBlockInnerProps) {
disablePlayback={skip || isRolling} disablePlayback={skip || isRolling}
/> />
{!skip && ( {!skip && (
<EventBlockChip <RundownEventChip
className={style.chipSection} className={style.chipSection}
id={eventId} id={eventId}
timeStart={timeStart} timeStart={timeStart}
@@ -164,8 +163,6 @@ function EventBlockInner(props: EventBlockInnerProps) {
); );
} }
export default memo(EventBlockInner);
function EndActionIcon(props: { action: EndAction; className: string }) { function EndActionIcon(props: { action: EndAction; className: string }) {
const { action, className } = props; const { action, className } = props;
const maybeActiveClasses = cx([action !== EndAction.None && style.active, className]); const maybeActiveClasses = cx([action !== EndAction.None && style.active, className]);
@@ -1,4 +1,4 @@
import { formatDelay, formatGap } from './EventBlock.utils'; import { formatDelay, formatGap } from './rundownEvent.utils';
import style from './RundownIndicators.module.scss'; import style from './RundownIndicators.module.scss';
@@ -9,9 +9,7 @@ interface RundownIndicatorProps {
gap: number; gap: number;
} }
export default function RundownIndicators(props: RundownIndicatorProps) { export default function RundownIndicators({ timeStart, delay, gap, isNextDay }: RundownIndicatorProps) {
const { timeStart, delay, gap, isNextDay } = props;
const hasGap = formatGap(gap, isNextDay); const hasGap = formatGap(gap, isNextDay);
const hasDelay = formatDelay(timeStart, delay); const hasDelay = formatDelay(timeStart, delay);
@@ -1,4 +1,4 @@
import { formatDelay } from '../EventBlock.utils'; import { formatDelay } from '../rundownEvent.utils';
describe('formatDelay()', () => { describe('formatDelay()', () => {
it('adds a given delay to the start time', () => { it('adds a given delay to the start time', () => {
@@ -9,9 +9,9 @@ import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime, useTimeUntilStart } from '../../../../common/utils/time'; import { formatDuration, formatTime, useTimeUntilStart } from '../../../../common/utils/time';
import { tooltipDelayFast } from '../../../../ontimeConfig'; import { tooltipDelayFast } from '../../../../ontimeConfig';
import style from './EventBlockChip.module.scss'; import style from './RundownEventChip.module.scss';
interface EventBlockChipProps { interface RundownEventChipProps {
id: string; id: string;
timeStart: number; timeStart: number;
delay: number; delay: number;
@@ -24,8 +24,18 @@ interface EventBlockChipProps {
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
} }
export default function EventBlockChip(props: EventBlockChipProps) { export default function RundownEventChip({
const { timeStart, delay, dayOffset, isPast, isLoaded, className, totalGap, id, duration, isLinkedToLoaded } = props; timeStart,
delay,
dayOffset,
isPast,
isLoaded,
className,
totalGap,
id,
duration,
isLinkedToLoaded,
}: RundownEventChipProps) {
const { playback } = usePlayback(); const { playback } = usePlayback();
if (isLoaded) { if (isLoaded) {
@@ -6,7 +6,7 @@ import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { setEventPlayback } from '../../../../common/hooks/useSocket'; import { setEventPlayback } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig'; import { tooltipDelayMid } from '../../../../ontimeConfig';
import style from '../EventBlock.module.scss'; import style from '../RundownEvent.module.scss';
const blockBtnStyle = { const blockBtnStyle = {
size: 'sm', size: 'sm',
@@ -23,7 +23,7 @@ const tooltipProps = {
openDelay: tooltipDelayMid, openDelay: tooltipDelayMid,
}; };
interface EventBlockPlaybackProps { interface RundownEventPlaybackProps {
eventId: string; eventId: string;
skip: boolean; skip: boolean;
isPlaying: boolean; isPlaying: boolean;
@@ -32,8 +32,15 @@ interface EventBlockPlaybackProps {
disablePlayback: boolean; disablePlayback: boolean;
} }
const EventBlockPlayback = (props: EventBlockPlaybackProps) => { export default memo(RundownEventPlayback);
const { eventId, skip, isPlaying, isPaused, loaded, disablePlayback } = props; function RundownEventPlayback({
eventId,
skip,
isPlaying,
isPaused,
loaded,
disablePlayback,
}: RundownEventPlaybackProps) {
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActions();
const toggleSkip = (event: MouseEvent) => { const toggleSkip = (event: MouseEvent) => {
@@ -123,6 +130,4 @@ const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
/> />
</div> </div>
); );
}; }
export default memo(EventBlockPlayback);
@@ -1,9 +1,9 @@
import { useTimer } from '../../../../common/hooks/useSocket'; import { useTimer } from '../../../../common/hooks/useSocket';
import { getProgress } from '../../../../common/utils/getProgress'; import { getProgress } from '../../../../common/utils/getProgress';
import style from './EventBlockProgressBar.module.scss'; import style from './RundownEventProgressBar.module.scss';
export default function EventBlockProgressBar() { export default function RundownEventProgressBar() {
const timer = useTimer(); const timer = useTimer();
const progress = getProgress(timer.current, timer.duration); const progress = getProgress(timer.current, timer.duration);
@@ -140,7 +140,7 @@ export function makeSortableList(order: EntryId[], entries: RundownEntries): Ent
// inside a block there are delays and events // inside a block there are delays and events
// there is no need for special handling // there is no need for special handling
flatIds.push(entry.id); flatIds.push(entry.id);
flatIds.push(...entry.events); flatIds.push(...entry.entries);
// close the block // close the block
flatIds.push(`end-${entry.id}`); flatIds.push(`end-${entry.id}`);
@@ -225,7 +225,7 @@ export function moveDown(entryId: EntryId, sortableData: EntryId[], entries: Run
return { destinationId: nextEntryId, order: 'after', isBlock: false }; return { destinationId: nextEntryId, order: 'after', isBlock: false };
} }
const firstBlockChild = entries[nextEntryId].events.at(0); const firstBlockChild = entries[nextEntryId].entries.at(0);
if (firstBlockChild) { if (firstBlockChild) {
// 2. add before the first child of the block // 2. add before the first child of the block
return { destinationId: firstBlockChild, order: 'before', isBlock: true }; return { destinationId: firstBlockChild, order: 'before', isBlock: true };
@@ -24,6 +24,7 @@ interface EventBlockTimerProps {
showLabels?: boolean; showLabels?: boolean;
} }
export default memo(TimeInputFlow);
function TimeInputFlow(props: EventBlockTimerProps) { function TimeInputFlow(props: EventBlockTimerProps) {
const { eventId, countToEnd, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, showLabels } = props; const { eventId, countToEnd, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, showLabels } = props;
const { updateEntry, updateTimer } = useEntryActions(); const { updateEntry, updateTimer } = useEntryActions();
@@ -135,5 +136,3 @@ function TimeInputFlow(props: EventBlockTimerProps) {
</> </>
); );
} }
export default memo(TimeInputFlow);
@@ -12,6 +12,8 @@ interface EventSelectionStore {
selectedEvents: Set<EntryId>; selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber; anchoredIndex: MaybeNumber;
cursor: MaybeString; cursor: MaybeString;
entryMode: 'event' | 'block' | null;
setSelectedBlock: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void; setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void; clearSelectedEvents: () => void;
clearMultiSelect: () => void; clearMultiSelect: () => void;
@@ -22,13 +24,21 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(), selectedEvents: new Set(),
anchoredIndex: null, anchoredIndex: null,
cursor: null, cursor: null,
setSelectedEvents: (selectionArgs) => { entryMode: null,
const { id, index, selectMode } = selectionArgs; setSelectedBlock: ({ id }) => {
const { selectedEvents, anchoredIndex } = get(); set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'block' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in block mode, we replace the selection and change the mode
if (entryMode === 'block') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event // on click, we replace selection with event
if (selectMode === 'click') { if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id }); return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
} }
// on ctrl + click, we toggle the selection of that event // on ctrl + click, we toggle the selection of that event
@@ -42,6 +52,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: selectedEvents.add(id), selectedEvents: selectedEvents.add(id),
anchoredIndex: index, anchoredIndex: index,
cursor: id, cursor: id,
entryMode: 'event',
}); });
} }
@@ -57,6 +68,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
return set({ return set({
selectedEvents, selectedEvents,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex, anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
}); });
} }
@@ -83,19 +95,23 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
return set({ return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]), selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index, anchoredIndex: index,
entryMode: 'event',
}); });
} }
}, },
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null }), clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => { clearMultiSelect: () => {
const { selectedEvents } = get(); const { selectedEvents } = get();
const [firstSelected] = selectedEvents; const [firstSelected] = selectedEvents;
set({ selectedEvents: new Set(firstSelected || undefined), anchoredIndex: null }); set({ selectedEvents: new Set(firstSelected || undefined), anchoredIndex: null, entryMode: null });
}, },
unselect: (id: string) => { unselect: (id: string) => {
const { selectedEvents } = get(); const { entryMode, selectedEvents } = get();
selectedEvents.delete(id); selectedEvents.delete(id);
set({ selectedEvents }); set({
selectedEvents,
entryMode: selectedEvents.size === 0 ? null : entryMode,
});
}, },
})); }));
@@ -10,7 +10,7 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown'; import { useFlatRundown } from '../../common/hooks-query/useRundown';
import { CuesheetOverview } from '../../features/overview/Overview'; import { CuesheetOverview } from '../../features/overview/Overview';
import CuesheetEventEditor from '../../features/rundown/event-editor/CuesheetEventEditor'; import CuesheetEventEditor from '../../features/rundown/entry-editor/CuesheetEventEditor';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
@@ -1,12 +1,15 @@
/* element attempts matching input styles */ /* element matching input styles */
.textInput { .textInput {
height: 2rem; height: 2rem;
background-color: transparent; background-color: transparent;
border-radius: 3px; border-radius: $component-border-radius-md;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
letter-spacing: 1px;
font-size: 1rem;
font-variant-numeric: tabular-nums;
&.delayed { &.delayed {
color: $ontime-delay-text; color: $ontime-delay-text;
@@ -10,7 +10,7 @@ const baseEvent = {
const baseBlock = { const baseBlock = {
type: SupportedEntry.Block, type: SupportedEntry.Block,
events: [], entries: [],
}; };
/** /**
@@ -570,7 +570,7 @@ describe('processRundown()', () => {
const rundown = makeRundown({ const rundown = makeRundown({
order: ['1'], order: ['1'],
entries: { entries: {
'1': makeOntimeBlock({ id: '1', events: ['100', '200', '300'] }), '1': makeOntimeBlock({ id: '1', entries: ['100', '200', '300'] }),
'100': makeOntimeEvent({ id: '100', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }), '100': makeOntimeEvent({ id: '100', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }),
'200': makeOntimeEvent({ id: '200', timeStart: 200, timeEnd: 300, duration: 100 }), '200': makeOntimeEvent({ id: '200', timeStart: 200, timeEnd: 300, duration: 100 }),
'300': makeOntimeEvent({ id: '300', timeStart: 300, timeEnd: 400, duration: 100 }), '300': makeOntimeEvent({ id: '300', timeStart: 300, timeEnd: 400, duration: 100 }),
@@ -584,7 +584,7 @@ describe('processRundown()', () => {
expect(generatedRundown.entries).toMatchObject({ expect(generatedRundown.entries).toMatchObject({
'1': { '1': {
type: SupportedEntry.Block, type: SupportedEntry.Block,
events: ['100', '200', '300'], entries: ['100', '200', '300'],
startTime: 100, startTime: 100,
endTime: 400, endTime: 400,
duration: 300, duration: 300,
@@ -601,15 +601,15 @@ describe('processRundown()', () => {
order: ['0', '1', '2', '3'], order: ['0', '1', '2', '3'],
entries: { entries: {
'0': makeOntimeEvent({ id: '0', timeStart: 0, timeEnd: 10, duration: 10, linkStart: false }), '0': makeOntimeEvent({ id: '0', timeStart: 0, timeEnd: 10, duration: 10, linkStart: false }),
'1': makeOntimeBlock({ id: '1', events: ['101', '102', '103'] }), '1': makeOntimeBlock({ id: '1', entries: ['101', '102', '103'] }),
'101': makeOntimeEvent({ id: '101', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }), '101': makeOntimeEvent({ id: '101', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }),
'102': makeOntimeEvent({ id: '102', timeStart: 200, timeEnd: 300, duration: 100, linkStart: true }), '102': makeOntimeEvent({ id: '102', timeStart: 200, timeEnd: 300, duration: 100, linkStart: true }),
'103': makeOntimeEvent({ id: '103', timeStart: 300, timeEnd: 400, duration: 100, linkStart: true }), '103': makeOntimeEvent({ id: '103', timeStart: 300, timeEnd: 400, duration: 100, linkStart: true }),
'2': makeOntimeBlock({ id: '2', events: ['201', '202', '203'] }), '2': makeOntimeBlock({ id: '2', entries: ['201', '202', '203'] }),
'201': makeOntimeEvent({ id: '201', timeStart: 500, timeEnd: 600, duration: 100, linkStart: false }), '201': makeOntimeEvent({ id: '201', timeStart: 500, timeEnd: 600, duration: 100, linkStart: false }),
'202': makeOntimeEvent({ id: '202', timeStart: 600, timeEnd: 700, duration: 100, linkStart: true }), '202': makeOntimeEvent({ id: '202', timeStart: 600, timeEnd: 700, duration: 100, linkStart: true }),
'203': makeOntimeEvent({ id: '203', timeStart: 700, timeEnd: 800, duration: 100, linkStart: true }), '203': makeOntimeEvent({ id: '203', timeStart: 700, timeEnd: 800, duration: 100, linkStart: true }),
'3': makeOntimeBlock({ id: '3', events: ['301', '302', '303'] }), '3': makeOntimeBlock({ id: '3', entries: ['301', '302', '303'] }),
'301': makeOntimeEvent({ id: '301', timeStart: 900, timeEnd: 1000, duration: 100, linkStart: false }), '301': makeOntimeEvent({ id: '301', timeStart: 900, timeEnd: 1000, duration: 100, linkStart: false }),
'302': makeOntimeEvent({ id: '302', timeStart: 1000, timeEnd: 1100, duration: 100, linkStart: true }), '302': makeOntimeEvent({ id: '302', timeStart: 1000, timeEnd: 1100, duration: 100, linkStart: true }),
'303': makeOntimeEvent({ id: '303', timeStart: 1100, timeEnd: 1200, duration: 100, linkStart: true }), '303': makeOntimeEvent({ id: '303', timeStart: 1100, timeEnd: 1200, duration: 100, linkStart: true }),
@@ -624,7 +624,7 @@ describe('processRundown()', () => {
'0': { type: SupportedEntry.Event, parent: null }, '0': { type: SupportedEntry.Event, parent: null },
'1': { '1': {
type: SupportedEntry.Block, type: SupportedEntry.Block,
events: ['101', '102', '103'], entries: ['101', '102', '103'],
startTime: 100, startTime: 100,
endTime: 400, endTime: 400,
duration: 300, duration: 300,
@@ -635,7 +635,7 @@ describe('processRundown()', () => {
'103': { parent: '1' }, '103': { parent: '1' },
'2': { '2': {
type: SupportedEntry.Block, type: SupportedEntry.Block,
events: ['201', '202', '203'], entries: ['201', '202', '203'],
startTime: 500, startTime: 500,
endTime: 800, endTime: 800,
duration: 300, duration: 300,
@@ -646,7 +646,7 @@ describe('processRundown()', () => {
'203': { id: '203', timeStart: 700, timeEnd: 800, duration: 100 }, '203': { id: '203', timeStart: 700, timeEnd: 800, duration: 100 },
'3': { '3': {
type: SupportedEntry.Block, type: SupportedEntry.Block,
events: ['301', '302', '303'], entries: ['301', '302', '303'],
startTime: 900, startTime: 900,
endTime: 1200, endTime: 1200,
duration: 300, duration: 300,
@@ -789,7 +789,7 @@ describe('rundownMutation.remove()', () => {
const rundown = makeRundown({ const rundown = makeRundown({
order: ['1', '4'], order: ['1', '4'],
entries: { entries: {
'1': makeOntimeBlock({ id: '1', events: ['2', '3'] }), '1': makeOntimeBlock({ id: '1', entries: ['2', '3'] }),
'2': makeOntimeEvent({ id: '2', parent: '1' }), '2': makeOntimeEvent({ id: '2', parent: '1' }),
'3': makeOntimeDelay({ id: '3', parent: '1' }), '3': makeOntimeDelay({ id: '3', parent: '1' }),
'4': makeOntimeEvent({ id: '4', parent: null }), '4': makeOntimeEvent({ id: '4', parent: null }),
@@ -811,7 +811,7 @@ describe('rundownMutation.remove()', () => {
const rundown = makeRundown({ const rundown = makeRundown({
order: ['1', '4'], order: ['1', '4'],
entries: { entries: {
'1': makeOntimeBlock({ id: '1', events: ['2', '3'] }), '1': makeOntimeBlock({ id: '1', entries: ['2', '3'] }),
'2': makeOntimeEvent({ id: '2', parent: '1' }), '2': makeOntimeEvent({ id: '2', parent: '1' }),
'3': makeOntimeDelay({ id: '3', parent: '1' }), '3': makeOntimeDelay({ id: '3', parent: '1' }),
'4': makeOntimeEvent({ id: '4', parent: null }), '4': makeOntimeEvent({ id: '4', parent: null }),
@@ -823,7 +823,7 @@ describe('rundownMutation.remove()', () => {
expect(rundown.order).toStrictEqual(['1', '4']); expect(rundown.order).toStrictEqual(['1', '4']);
expect(rundown.entries).not.toHaveProperty('2'); expect(rundown.entries).not.toHaveProperty('2');
expect(rundown.entries['1']).toMatchObject({ expect(rundown.entries['1']).toMatchObject({
events: ['3'], entries: ['3'],
}); });
}); });
}); });
@@ -853,7 +853,7 @@ describe('rundownMutation.reorder()', () => {
const rundown = makeRundown({ const rundown = makeRundown({
order: ['1', '2', '3'], order: ['1', '2', '3'],
entries: { entries: {
'1': makeOntimeBlock({ id: '1', events: [] }), '1': makeOntimeBlock({ id: '1', entries: [] }),
'2': makeOntimeEvent({ id: '2', parent: null }), '2': makeOntimeEvent({ id: '2', parent: null }),
'3': makeOntimeEvent({ id: '3', parent: null }), '3': makeOntimeEvent({ id: '3', parent: null }),
}, },
@@ -863,7 +863,7 @@ describe('rundownMutation.reorder()', () => {
expect(rundown.order).toStrictEqual(['1', '2']); expect(rundown.order).toStrictEqual(['1', '2']);
expect(rundown.entries['1']).toMatchObject({ expect(rundown.entries['1']).toMatchObject({
events: ['3'], entries: ['3'],
}); });
expect(rundown.entries['3']).toMatchObject({ expect(rundown.entries['3']).toMatchObject({
parent: '1', parent: '1',
@@ -875,7 +875,7 @@ describe('rundownMutation.reorder()', () => {
order: ['1', '2'], order: ['1', '2'],
flatOrder: ['1', '11', '2'], flatOrder: ['1', '11', '2'],
entries: { entries: {
'1': makeOntimeBlock({ id: '1', events: ['11'] }), '1': makeOntimeBlock({ id: '1', entries: ['11'] }),
'11': makeOntimeEvent({ id: '11', parent: '1' }), '11': makeOntimeEvent({ id: '11', parent: '1' }),
'2': makeOntimeEvent({ id: '2', parent: null }), '2': makeOntimeEvent({ id: '2', parent: null }),
}, },
@@ -885,7 +885,7 @@ describe('rundownMutation.reorder()', () => {
expect(rundown.order).toStrictEqual(['1']); expect(rundown.order).toStrictEqual(['1']);
expect(rundown.entries['1']).toMatchObject({ expect(rundown.entries['1']).toMatchObject({
events: ['2', '11'], entries: ['2', '11'],
}); });
expect(rundown.entries['2']).toMatchObject({ expect(rundown.entries['2']).toMatchObject({
parent: '1', parent: '1',
@@ -931,7 +931,7 @@ describe('rundownMutation.reorder()', () => {
order: ['1', '2'], order: ['1', '2'],
flatOrder: ['1', '11', '2'], flatOrder: ['1', '11', '2'],
entries: { entries: {
'1': makeOntimeBlock({ id: '1', events: ['11'] }), '1': makeOntimeBlock({ id: '1', entries: ['11'] }),
'11': makeOntimeEvent({ id: '11', parent: '1' }), '11': makeOntimeEvent({ id: '11', parent: '1' }),
'2': makeOntimeEvent({ id: '2', parent: null }), '2': makeOntimeEvent({ id: '2', parent: null }),
}, },
@@ -941,7 +941,7 @@ describe('rundownMutation.reorder()', () => {
expect(rundown.order).toStrictEqual(['1', '11', '2']); expect(rundown.order).toStrictEqual(['1', '11', '2']);
expect(rundown.entries['1']).toMatchObject({ expect(rundown.entries['1']).toMatchObject({
events: [], entries: [],
}); });
expect(rundown.entries['2']).toMatchObject({ expect(rundown.entries['2']).toMatchObject({
parent: null, parent: null,
@@ -953,9 +953,9 @@ describe('rundownMutation.reorder()', () => {
order: ['1', '2'], order: ['1', '2'],
flatOrder: ['1', '11', '2', '22'], flatOrder: ['1', '11', '2', '22'],
entries: { entries: {
'1': makeOntimeBlock({ id: '1', events: ['11'] }), '1': makeOntimeBlock({ id: '1', entries: ['11'] }),
'11': makeOntimeEvent({ id: '11', parent: '1' }), '11': makeOntimeEvent({ id: '11', parent: '1' }),
'2': makeOntimeBlock({ id: '2', events: ['22'] }), '2': makeOntimeBlock({ id: '2', entries: ['22'] }),
'22': makeOntimeEvent({ id: '22', parent: '2' }), '22': makeOntimeEvent({ id: '22', parent: '2' }),
}, },
}); });
@@ -964,10 +964,10 @@ describe('rundownMutation.reorder()', () => {
expect(rundown.order).toStrictEqual(['1', '2']); expect(rundown.order).toStrictEqual(['1', '2']);
expect(rundown.entries['1']).toMatchObject({ expect(rundown.entries['1']).toMatchObject({
events: [], entries: [],
}); });
expect(rundown.entries['2']).toMatchObject({ expect(rundown.entries['2']).toMatchObject({
events: ['11', '22'], entries: ['11', '22'],
}); });
expect(rundown.entries['11']).toMatchObject({ expect(rundown.entries['11']).toMatchObject({
parent: '2', parent: '2',
@@ -979,8 +979,8 @@ describe('rundownMutation.reorder()', () => {
order: ['1', '2'], order: ['1', '2'],
flatOrder: ['1', '2', '22'], flatOrder: ['1', '2', '22'],
entries: { entries: {
'1': makeOntimeBlock({ id: '1', events: [] }), '1': makeOntimeBlock({ id: '1', entries: [] }),
'2': makeOntimeBlock({ id: '2', events: ['22'] }), '2': makeOntimeBlock({ id: '2', entries: ['22'] }),
'22': makeOntimeEvent({ id: '22', parent: '2' }), '22': makeOntimeEvent({ id: '22', parent: '2' }),
}, },
}); });
@@ -989,10 +989,10 @@ describe('rundownMutation.reorder()', () => {
expect(rundown.order).toStrictEqual(['1', '2']); expect(rundown.order).toStrictEqual(['1', '2']);
expect(rundown.entries['1']).toMatchObject({ expect(rundown.entries['1']).toMatchObject({
events: ['22'], entries: ['22'],
}); });
expect(rundown.entries['2']).toMatchObject({ expect(rundown.entries['2']).toMatchObject({
events: [], entries: [],
}); });
expect(rundown.entries['22']).toMatchObject({ expect(rundown.entries['22']).toMatchObject({
parent: '1', parent: '1',
@@ -1004,9 +1004,9 @@ describe('rundownMutation.reorder()', () => {
order: ['1', '2'], order: ['1', '2'],
flatOrder: ['1', '11', '2', '22'], flatOrder: ['1', '11', '2', '22'],
entries: { entries: {
'1': makeOntimeBlock({ id: '1', events: ['11'] }), '1': makeOntimeBlock({ id: '1', entries: ['11'] }),
'11': makeOntimeEvent({ id: '11', parent: '1' }), '11': makeOntimeEvent({ id: '11', parent: '1' }),
'2': makeOntimeBlock({ id: '2', events: ['22'] }), '2': makeOntimeBlock({ id: '2', entries: ['22'] }),
'22': makeOntimeEvent({ id: '22', parent: '2' }), '22': makeOntimeEvent({ id: '22', parent: '2' }),
}, },
}); });
@@ -1017,13 +1017,13 @@ describe('rundownMutation.reorder()', () => {
// expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']);
// expect(changeList).toStrictEqual(['1', '2', '11', '22']); // expect(changeList).toStrictEqual(['1', '2', '11', '22']);
expect(rundown.entries['1']).toMatchObject({ expect(rundown.entries['1']).toMatchObject({
events: ['11'], entries: ['11'],
}); });
expect(rundown.entries['11']).toMatchObject({ expect(rundown.entries['11']).toMatchObject({
parent: '1', parent: '1',
}); });
expect(rundown.entries['2']).toMatchObject({ expect(rundown.entries['2']).toMatchObject({
events: [], entries: [],
}); });
expect(rundown.entries['22']).toMatchObject({ expect(rundown.entries['22']).toMatchObject({
parent: null, parent: null,
@@ -1035,9 +1035,9 @@ describe('rundownMutation.reorder()', () => {
order: ['1', '2'], order: ['1', '2'],
flatOrder: ['1', '11', '2', '22'], flatOrder: ['1', '11', '2', '22'],
entries: { entries: {
'1': makeOntimeBlock({ id: '1', events: ['11'] }), '1': makeOntimeBlock({ id: '1', entries: ['11'] }),
'11': makeOntimeEvent({ id: '11', parent: '1' }), '11': makeOntimeEvent({ id: '11', parent: '1' }),
'2': makeOntimeBlock({ id: '2', events: ['22'] }), '2': makeOntimeBlock({ id: '2', entries: ['22'] }),
'22': makeOntimeEvent({ id: '22', parent: '2' }), '22': makeOntimeEvent({ id: '22', parent: '2' }),
}, },
}); });
@@ -1046,13 +1046,13 @@ describe('rundownMutation.reorder()', () => {
expect(rundown.order).toStrictEqual(['1', '11', '2']); expect(rundown.order).toStrictEqual(['1', '11', '2']);
expect(rundown.entries['1']).toMatchObject({ expect(rundown.entries['1']).toMatchObject({
events: [], entries: [],
}); });
expect(rundown.entries['11']).toMatchObject({ expect(rundown.entries['11']).toMatchObject({
parent: null, parent: null,
}); });
expect(rundown.entries['2']).toMatchObject({ expect(rundown.entries['2']).toMatchObject({
events: ['22'], entries: ['22'],
}); });
expect(rundown.entries['22']).toMatchObject({ expect(rundown.entries['22']).toMatchObject({
parent: '2', parent: '2',
@@ -1374,7 +1374,7 @@ describe('rundownMutation.applyDelay()', () => {
order: ['1', 'block', '2', '3'], order: ['1', 'block', '2', '3'],
entries: { entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
block: makeOntimeBlock({ id: 'block', events: ['delay'] }), block: makeOntimeBlock({ id: 'block', entries: ['delay'] }),
delay: makeOntimeDelay({ id: 'delay', duration: 100, parent: 'block' }), delay: makeOntimeDelay({ id: 'delay', duration: 100, parent: 'block' }),
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 200, duration: 100, linkStart: true }), '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 200, duration: 100, linkStart: true }),
'3': makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 300, duration: 100, linkStart: true }), '3': makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 300, duration: 100, linkStart: true }),
@@ -1417,7 +1417,7 @@ describe('rundownMutation.applyDelay()', () => {
entries: { entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
delay: makeOntimeDelay({ id: 'delay', duration: 100 }), delay: makeOntimeDelay({ id: 'delay', duration: 100 }),
block: makeOntimeBlock({ id: 'block', events: ['block-1'] }), block: makeOntimeBlock({ id: 'block', entries: ['block-1'] }),
'block-1': makeOntimeEvent({ 'block-1': makeOntimeEvent({
id: 'block-1', id: 'block-1',
timeStart: 100, timeStart: 100,
@@ -1520,7 +1520,7 @@ describe('rundownMutation.clone()', () => {
const testRundown = makeRundown({ const testRundown = makeRundown({
order: ['1'], order: ['1'],
entries: { entries: {
'1': makeOntimeBlock({ id: '1', events: ['1a'] }), '1': makeOntimeBlock({ id: '1', entries: ['1a'] }),
'1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }), '1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }),
}, },
}); });
@@ -1528,7 +1528,7 @@ describe('rundownMutation.clone()', () => {
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1a']); const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1a']);
expect(testRundown.order).toStrictEqual(['1']); expect(testRundown.order).toStrictEqual(['1']);
expect(testRundown.entries['1']).toMatchObject({ events: ['1a', newEntry.id] }); expect(testRundown.entries['1']).toMatchObject({ entries: ['1a', newEntry.id] });
expect(testRundown.entries[newEntry.id]).toMatchObject({ expect(testRundown.entries[newEntry.id]).toMatchObject({
type: SupportedEntry.Event, type: SupportedEntry.Event,
parent: '1', parent: '1',
@@ -1540,7 +1540,7 @@ describe('rundownMutation.clone()', () => {
const testRundown = makeRundown({ const testRundown = makeRundown({
order: ['1'], order: ['1'],
entries: { entries: {
'1': makeOntimeBlock({ id: '1', title: 'top', events: ['1a'] }), '1': makeOntimeBlock({ id: '1', title: 'top', entries: ['1a'] }),
'1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }), '1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }),
}, },
}); });
@@ -1550,9 +1550,9 @@ describe('rundownMutation.clone()', () => {
expect(testRundown.order).toStrictEqual(['1', newEntry.id]); expect(testRundown.order).toStrictEqual(['1', newEntry.id]);
expect(testRundown.entries[newEntry.id]).toMatchObject({ expect(testRundown.entries[newEntry.id]).toMatchObject({
type: SupportedEntry.Block, type: SupportedEntry.Block,
events: [expect.any(String)], entries: [expect.any(String)],
}); });
expect((testRundown.entries[newEntry.id] as OntimeBlock).events[0]).not.toBe('1a'); expect((testRundown.entries[newEntry.id] as OntimeBlock).entries[0]).not.toBe('1a');
}); });
}); });
@@ -1575,7 +1575,7 @@ describe('rundownMutation.group()', () => {
expect(rundown.entries).toMatchObject({ expect(rundown.entries).toMatchObject({
[blockId]: { [blockId]: {
type: SupportedEntry.Block, type: SupportedEntry.Block,
events: ['1', '2'], entries: ['1', '2'],
}, },
'1': { id: '1', type: SupportedEntry.Event, parent: blockId }, '1': { id: '1', type: SupportedEntry.Event, parent: blockId },
'2': { id: '2', type: SupportedEntry.Event, parent: blockId }, '2': { id: '2', type: SupportedEntry.Event, parent: blockId },
@@ -1590,7 +1590,7 @@ describe('rundownMutation.ungroup()', () => {
order: ['1', '2'], order: ['1', '2'],
entries: { entries: {
'1': makeOntimeEvent({ id: '1', cue: 'data1', parent: null }), '1': makeOntimeEvent({ id: '1', cue: 'data1', parent: null }),
'2': makeOntimeBlock({ id: '2', events: ['21', '22'] }), '2': makeOntimeBlock({ id: '2', entries: ['21', '22'] }),
'21': makeOntimeEvent({ id: '21', cue: 'data21', parent: '2' }), '21': makeOntimeEvent({ id: '21', cue: 'data21', parent: '2' }),
'22': makeOntimeEvent({ id: '22', cue: 'data22', parent: '2' }), '22': makeOntimeEvent({ id: '22', cue: 'data22', parent: '2' }),
}, },
@@ -47,7 +47,7 @@ describe('parseRundown()', () => {
flatOrder: ['1', '2', '3', '4'], flatOrder: ['1', '2', '3', '4'],
entries: { entries: {
'1': { id: '1', type: SupportedEntry.Event, title: 'test', skip: false } as OntimeEvent, // OK '1': { id: '1', type: SupportedEntry.Event, title: 'test', skip: false } as OntimeEvent, // OK
'2': { id: '1', type: SupportedEntry.Block, title: 'test 2', skip: false } as OntimeBlock, // duplicate ID '2': { id: '1', type: SupportedEntry.Block, title: 'test 2' } as OntimeBlock, // duplicate ID
'3': {} as OntimeEvent, // no data '3': {} as OntimeEvent, // no data
'4': { id: '4', title: 'test 2', skip: false } as OntimeEvent, // no type '4': { id: '4', title: 'test 2', skip: false } as OntimeEvent, // no type
}, },
@@ -220,7 +220,7 @@ describe('parseRundown()', () => {
order: ['block'], order: ['block'],
flatOrder: ['block'], flatOrder: ['block'],
entries: { entries: {
block: makeOntimeBlock({ id: 'block', events: ['1', '2'] }), block: makeOntimeBlock({ id: 'block', entries: ['1', '2'] }),
'1': makeOntimeEvent({ id: '1' }), '1': makeOntimeEvent({ id: '1' }),
'2': makeOntimeEvent({ id: '2' }), '2': makeOntimeEvent({ id: '2' }),
}, },
@@ -229,7 +229,7 @@ describe('parseRundown()', () => {
const parsedRundown = parseRundown(rundown, {}); const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.order.length).toEqual(1); expect(parsedRundown.order.length).toEqual(1);
expect(parsedRundown.entries.block).toMatchObject({ events: ['1', '2'] }); expect(parsedRundown.entries.block).toMatchObject({ entries: ['1', '2'] });
expect(Object.keys(parsedRundown.entries).length).toEqual(3); expect(Object.keys(parsedRundown.entries).length).toEqual(3);
}); });
}); });
+19 -19
View File
@@ -48,7 +48,7 @@ const cachedRundown: Rundown = {
id: '', id: '',
title: '', title: '',
order: [], order: [],
flatOrder: [], // TODO: remove in favour of the metadata flatEntryOrder flatOrder: [],
entries: {}, entries: {},
revision: 0, revision: 0,
}; };
@@ -150,7 +150,7 @@ export function createTransaction(options: TransactionOptions): Transaction {
cachedRundown.title = rundown.title; cachedRundown.title = rundown.title;
cachedRundown.entries = entries; cachedRundown.entries = entries;
cachedRundown.order = order; cachedRundown.order = order;
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder cachedRundown.flatOrder = metadata.flatEntryOrder;
customFieldsMetadata.assigned = assignedCustomFields; customFieldsMetadata.assigned = assignedCustomFields;
rundownMetadata = metadata; rundownMetadata = metadata;
} }
@@ -194,12 +194,12 @@ function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, pare
// 1. inserting an entry inside a block // 1. inserting an entry inside a block
const parentBlock = rundown.entries[parentId] as OntimeBlock; const parentBlock = rundown.entries[parentId] as OntimeBlock;
if (afterId) { if (afterId) {
const atEventsIndex = parentBlock.events.indexOf(afterId) + 1; const atEventsIndex = parentBlock.entries.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1; const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
parentBlock.events = insertAtIndex(atEventsIndex, entry.id, parentBlock.events); parentBlock.entries = insertAtIndex(atEventsIndex, entry.id, parentBlock.entries);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else { } else {
parentBlock.events = insertAtIndex(0, entry.id, parentBlock.events); parentBlock.entries = insertAtIndex(0, entry.id, parentBlock.entries);
const atFlatIndex = rundown.flatOrder.indexOf(parentId) + 1; const atFlatIndex = rundown.flatOrder.indexOf(parentId) + 1;
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} }
@@ -247,8 +247,8 @@ function edit(rundown: Rundown, patch: PatchWithId): { entry: OntimeEntry; didIn
function remove(rundown: Rundown, entry: OntimeEntry) { function remove(rundown: Rundown, entry: OntimeEntry) {
if (isOntimeBlock(entry)) { if (isOntimeBlock(entry)) {
// for ontime blocks, we need to iterate through the children and delete them // for ontime blocks, we need to iterate through the children and delete them
for (let i = 0; i < entry.events.length; i++) { for (let i = 0; i < entry.entries.length; i++) {
const nestedEntryId = entry.events[i]; const nestedEntryId = entry.entries[i];
deleteEntry(nestedEntryId); deleteEntry(nestedEntryId);
} }
} else if (entry.parent) { } else if (entry.parent) {
@@ -256,8 +256,8 @@ function remove(rundown: Rundown, entry: OntimeEntry) {
const parentBlock = rundown.entries[entry.parent] as OntimeBlock; const parentBlock = rundown.entries[entry.parent] as OntimeBlock;
if (parentBlock) { if (parentBlock) {
// we call a mutation to the parent event to remove the entry from the events // we call a mutation to the parent event to remove the entry from the events
const filteredEvents = deleteById(parentBlock.events, entry.id); const filteredEvents = deleteById(parentBlock.entries, entry.id);
edit(rundown, { id: parentBlock.id, events: filteredEvents }); edit(rundown, { id: parentBlock.id, entries: filteredEvents });
} }
} }
deleteEntry(entry.id); deleteEntry(entry.id);
@@ -300,8 +300,8 @@ function reorder(rundown: Rundown, eventFrom: OntimeEntry, eventTo: OntimeEntry,
eventFrom.parent = toParent; eventFrom.parent = toParent;
} }
const sourceArray = fromParent === null ? rundown.order : (rundown.entries[fromParent] as OntimeBlock).events; const sourceArray = fromParent === null ? rundown.order : (rundown.entries[fromParent] as OntimeBlock).entries;
const destinationArray = toParent === null ? rundown.order : (rundown.entries[toParent] as OntimeBlock).events; const destinationArray = toParent === null ? rundown.order : (rundown.entries[toParent] as OntimeBlock).entries;
const fromIndex = sourceArray.indexOf(eventFrom.id); const fromIndex = sourceArray.indexOf(eventFrom.id);
const toIndex = (() => { const toIndex = (() => {
@@ -442,8 +442,8 @@ function clone(rundown: Rundown, entry: OntimeEntry): OntimeEntry {
const newBlock = cloneBlock(entry, getUniqueId(rundown)); const newBlock = cloneBlock(entry, getUniqueId(rundown));
const nestedIds: EntryId[] = []; const nestedIds: EntryId[] = [];
for (let i = 0; i < entry.events.length; i++) { for (let i = 0; i < entry.entries.length; i++) {
const nestedEntryId = entry.events[i]; const nestedEntryId = entry.entries[i];
const nestedEntry = rundown.entries[nestedEntryId]; const nestedEntry = rundown.entries[nestedEntryId];
if (!nestedEntry) { if (!nestedEntry) {
continue; continue;
@@ -461,7 +461,7 @@ function clone(rundown: Rundown, entry: OntimeEntry): OntimeEntry {
// indexes + 1 since we are inserting after the cloned block // indexes + 1 since we are inserting after the cloned block
const atIndex = rundown.order.indexOf(entry.id) + 1; const atIndex = rundown.order.indexOf(entry.id) + 1;
newBlock.events = nestedIds; newBlock.entries = nestedIds;
newBlock.title = `${entry.title || 'Untitled'} (copy)`; newBlock.title = `${entry.title || 'Untitled'} (copy)`;
rundown.entries[newBlock.id] = newBlock; rundown.entries[newBlock.id] = newBlock;
@@ -504,7 +504,7 @@ function group(rundown: Rundown, entryIds: EntryId[]): OntimeBlock {
rundown.order = rundown.order.filter((id) => id !== entryId); rundown.order = rundown.order.filter((id) => id !== entryId);
} }
newBlock.events = nestedEvents; newBlock.entries = nestedEvents;
const insertIndex = Math.max(0, firstIndex); const insertIndex = Math.max(0, firstIndex);
// we have filtered the items from the order // we have filtered the items from the order
// we will insert them now, with only the block at top level ... // we will insert them now, with only the block at top level ...
@@ -519,7 +519,7 @@ function group(rundown: Rundown, entryIds: EntryId[]): OntimeBlock {
*/ */
function ungroup(rundown: Rundown, block: OntimeBlock) { function ungroup(rundown: Rundown, block: OntimeBlock) {
// get the events from the block and merge them into the order where the block was // get the events from the block and merge them into the order where the block was
const nestedEvents = block.events; const nestedEvents = block.entries;
const blockIndex = rundown.order.indexOf(block.id); const blockIndex = rundown.order.indexOf(block.id);
rundown.order.splice(blockIndex, 1, ...nestedEvents); rundown.order.splice(blockIndex, 1, ...nestedEvents);
@@ -714,8 +714,8 @@ export function processRundown(
const blockEvents: EntryId[] = []; const blockEvents: EntryId[] = [];
// check if the block contains nested entries // check if the block contains nested entries
for (let j = 0; j < processedEntry.events.length; j++) { for (let j = 0; j < processedEntry.entries.length; j++) {
const nestedEntryId = processedEntry.events[j]; const nestedEntryId = processedEntry.entries[j];
const nestedEntry = initialRundown.entries[nestedEntryId]; const nestedEntry = initialRundown.entries[nestedEntryId];
if (!nestedEntry) { if (!nestedEntry) {
@@ -750,7 +750,7 @@ export function processRundown(
processedEntry.startTime = blockStartTime; processedEntry.startTime = blockStartTime;
processedEntry.endTime = blockEndTime; processedEntry.endTime = blockEndTime;
processedEntry.isFirstLinked = isFirstLinked; processedEntry.isFirstLinked = isFirstLinked;
processedEntry.events = blockEvents; processedEntry.entries = blockEvents;
} }
} }
@@ -116,8 +116,8 @@ export function parseRundown(
} else if (isOntimeDelay(event)) { } else if (isOntimeDelay(event)) {
newEvent = { ...delayDef, duration: event.duration, id }; newEvent = { ...delayDef, duration: event.duration, id };
} else if (isOntimeBlock(event)) { } else if (isOntimeBlock(event)) {
for (let i = 0; i < event.events.length; i++) { for (let i = 0; i < event.entries.length; i++) {
const nestedEventId = event.events[i]; const nestedEventId = event.entries[i];
const nestedEvent = rundown.entries[nestedEventId]; const nestedEvent = rundown.entries[nestedEventId];
if (isOntimeEvent(nestedEvent)) { if (isOntimeEvent(nestedEvent)) {
@@ -149,8 +149,8 @@ export function parseRundown(
...blockDef, ...blockDef,
title: event.title, title: event.title,
note: event.note, note: event.note,
events: event.events?.filter((eventId) => Object.hasOwn(rundown.entries, eventId)) ?? [], entries: event.entries?.filter((eventId) => Object.hasOwn(rundown.entries, eventId)) ?? [],
skip: event.skip, isNextDay: event.isNextDay,
colour: event.colour, colour: event.colour,
custom: { ...event.custom }, custom: { ...event.custom },
id, id,
@@ -99,19 +99,15 @@ router.patch('/swap', rundownSwapValidator, async (req: Request, res: Response<R
} }
}); });
router.patch( router.patch('/applydelay/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
'/applydelay/:id', try {
paramsWithId, const newRundown = await applyDelay(req.params.id);
async (req: Request, res: Response<Rundown | ErrorResponse>) => { res.status(200).send(newRundown);
try { } catch (error) {
const newRundown = await applyDelay(req.params.id); const message = getErrorMessage(error);
res.status(200).send(newRundown); res.status(400).send({ message });
} catch (error) { }
const message = getErrorMessage(error); });
res.status(400).send({ message });
}
},
);
router.post('/clone/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => { router.post('/clone/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try { try {
@@ -133,19 +129,15 @@ router.post('/group', rundownArrayOfIds, async (req: Request, res: Response<Rund
} }
}); });
router.post( router.post('/ungroup/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
'/ungroup/:id', try {
paramsWithId, const newRundown = await ungroupEntries(req.params.id);
async (req: Request, res: Response<Rundown | ErrorResponse>) => { res.status(200).send(newRundown);
try { } catch (error) {
const newRundown = await ungroupEntries(req.params.id); const message = getErrorMessage(error);
res.status(200).send(newRundown); res.status(400).send({ message });
} catch (error) { }
const message = getErrorMessage(error); });
res.status(400).send({ message });
}
},
);
router.delete('/', rundownArrayOfIds, async (req: Request, res: Response<MessageResponse | ErrorResponse>) => { router.delete('/', rundownArrayOfIds, async (req: Request, res: Response<MessageResponse | ErrorResponse>) => {
try { try {
@@ -347,7 +347,7 @@ export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
// notify timer and external services of change // notify timer and external services of change
if (isOntimeBlock(newEntry)) { if (isOntimeBlock(newEntry)) {
notifyChanges(rundownMetadata, revision, { timer: newEntry.events, external: true }); notifyChanges(rundownMetadata, revision, { timer: newEntry.entries, external: true });
} else if (isOntimeEvent(newEntry)) { } else if (isOntimeEvent(newEntry)) {
notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true }); notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true });
} else if (isOntimeDelay(newEntry)) { } else if (isOntimeDelay(newEntry)) {
@@ -156,8 +156,9 @@ export function createBlock(patch?: Partial<OntimeBlock>): OntimeBlock {
type: SupportedEntry.Block, type: SupportedEntry.Block,
title: patch.title ?? '', title: patch.title ?? '',
note: patch.note ?? '', note: patch.note ?? '',
events: patch.events ?? [], entries: patch.entries ?? [],
skip: patch.skip ?? false, isNextDay: patch.isNextDay ?? false,
targetDuration: patch.targetDuration ?? null,
colour: makeString(patch.colour, ''), colour: makeString(patch.colour, ''),
custom: patch.custom ?? {}, custom: patch.custom ?? {},
revision: 0, revision: 0,
@@ -288,7 +289,7 @@ export function cloneBlock(entry: OntimeBlock, newId: EntryId): OntimeBlock {
newEntry.id = newId; newEntry.id = newId;
// in blocks, we need to remove the events references // in blocks, we need to remove the events references
newEntry.events = []; newEntry.entries = [];
newEntry.revision = 0; newEntry.revision = 0;
return newEntry; return newEntry;
} }
+9 -6
View File
@@ -41,11 +41,12 @@ export const demoDb: DatabaseModel = {
entries: { entries: {
block: { block: {
type: SupportedEntry.Block, type: SupportedEntry.Block,
events: ['32d31', '21cd2', '0b371', '3cd28', 'e457f'], entries: ['32d31', '21cd2', '0b371', '3cd28', 'e457f'],
id: 'block', id: 'block',
title: 'Test Block', title: 'Test Block',
note: '', note: '',
skip: false, isNextDay: false,
targetDuration: null,
colour: 'hotpink', colour: 'hotpink',
revision: 0, revision: 0,
startTime: null, startTime: null,
@@ -210,8 +211,9 @@ export const demoDb: DatabaseModel = {
title: 'Lunch break', title: 'Lunch break',
note: '', note: '',
colour: '', colour: '',
events: [], entries: [],
skip: false, isNextDay: false,
targetDuration: null,
custom: {}, custom: {},
revision: 0, revision: 0,
startTime: null, startTime: null,
@@ -372,8 +374,9 @@ export const demoDb: DatabaseModel = {
title: 'Afternoon break', title: 'Afternoon break',
note: '', note: '',
colour: '', colour: '',
events: [], entries: [],
skip: false, isNextDay: false,
targetDuration: null,
custom: {}, custom: {},
revision: 0, revision: 0,
startTime: null, startTime: null,
+3 -2
View File
@@ -44,8 +44,9 @@ export const block: Omit<OntimeBlock, 'id'> = {
type: SupportedEntry.Block, type: SupportedEntry.Block,
title: '', title: '',
note: '', note: '',
events: [], entries: [],
skip: false, isNextDay: false,
targetDuration: null,
colour: '', colour: '',
custom: {}, custom: {},
// !==== RUNTIME METADATA ====! // // !==== RUNTIME METADATA ====! //
@@ -375,9 +375,9 @@ describe('loadBlock', () => {
const rundown = makeRundown({ const rundown = makeRundown({
entries: { entries: {
0: makeOntimeEvent({ id: '0', parent: null }), 0: makeOntimeEvent({ id: '0', parent: null }),
1: makeOntimeBlock({ id: '1', events: ['11'] }), 1: makeOntimeBlock({ id: '1', entries: ['11'] }),
11: makeOntimeEvent({ id: '11', parent: '1' }), 11: makeOntimeEvent({ id: '11', parent: '1' }),
2: makeOntimeBlock({ id: '2', events: [] }), 2: makeOntimeBlock({ id: '2', entries: [] }),
3: makeOntimeEvent({ id: '3', parent: null }), 3: makeOntimeEvent({ id: '3', parent: null }),
}, },
order: ['0', '1', '2', '3'], order: ['0', '1', '2', '3'],
@@ -400,9 +400,9 @@ describe('loadBlock', () => {
const rundown = makeRundown({ const rundown = makeRundown({
entries: { entries: {
0: makeOntimeEvent({ id: '0', parent: null }), 0: makeOntimeEvent({ id: '0', parent: null }),
1: makeOntimeBlock({ id: '1', events: ['11'] }), 1: makeOntimeBlock({ id: '1', entries: ['11'] }),
11: makeOntimeEvent({ id: '11', parent: '1' }), 11: makeOntimeEvent({ id: '11', parent: '1' }),
2: makeOntimeBlock({ id: '2', events: ['22'] }), 2: makeOntimeBlock({ id: '2', entries: ['22'] }),
22: makeOntimeEvent({ id: '22', parent: '2' }), 22: makeOntimeEvent({ id: '22', parent: '2' }),
}, },
order: ['0', '1', '2'], order: ['0', '1', '2'],
@@ -425,9 +425,9 @@ describe('loadBlock', () => {
const rundown = makeRundown({ const rundown = makeRundown({
entries: { entries: {
0: makeOntimeEvent({ id: '0', parent: null }), 0: makeOntimeEvent({ id: '0', parent: null }),
1: makeOntimeBlock({ id: '1', events: ['11'] }), 1: makeOntimeBlock({ id: '1', entries: ['11'] }),
11: makeOntimeEvent({ id: '11', parent: '1' }), 11: makeOntimeEvent({ id: '11', parent: '1' }),
2: makeOntimeBlock({ id: '2', events: ['22'] }), 2: makeOntimeBlock({ id: '2', entries: ['22'] }),
22: makeOntimeEvent({ id: '22', parent: '2' }), 22: makeOntimeEvent({ id: '22', parent: '2' }),
}, },
order: ['0', '1', '2'], order: ['0', '1', '2'],
@@ -452,7 +452,7 @@ describe('loadBlock', () => {
test('from block to same block will keep startedAt', () => { test('from block to same block will keep startedAt', () => {
const rundown = makeRundown({ const rundown = makeRundown({
entries: { entries: {
0: makeOntimeBlock({ id: '0', events: ['1', '2'] }), 0: makeOntimeBlock({ id: '0', entries: ['1', '2'] }),
1: makeOntimeEvent({ id: '1', parent: '0' }), 1: makeOntimeEvent({ id: '1', parent: '0' }),
2: makeOntimeEvent({ id: '2', parent: '0' }), 2: makeOntimeEvent({ id: '2', parent: '0' }),
}, },
+2 -2
View File
@@ -168,7 +168,7 @@
"title": "Lunch break", "title": "Lunch break",
"note": "", "note": "",
"colour": "", "colour": "",
"events": [], "entries": [],
"skip": false, "skip": false,
"custom": {}, "custom": {},
"revision": 0, "revision": 0,
@@ -323,7 +323,7 @@
"title": "Afternoon break", "title": "Afternoon break",
"note": "", "note": "",
"colour": "", "colour": "",
"events": [], "entries": [],
"skip": false, "skip": false,
"custom": {}, "custom": {},
"revision": 0, "revision": 0,
+2 -2
View File
@@ -186,7 +186,7 @@
"title": "Lunch break", "title": "Lunch break",
"note": "", "note": "",
"colour": "", "colour": "",
"events": [], "entries": [],
"skip": false, "skip": false,
"custom": {}, "custom": {},
"revision": 0, "revision": 0,
@@ -341,7 +341,7 @@
"title": "Afternoon break", "title": "Afternoon break",
"note": "", "note": "",
"colour": "", "colour": "",
"events": [], "entries": [],
"skip": false, "skip": false,
"custom": {}, "custom": {},
"revision": 0, "revision": 0,
@@ -23,8 +23,9 @@ export type OntimeBlock = OntimeBaseEvent & {
type: SupportedEntry.Block; type: SupportedEntry.Block;
title: string; title: string;
note: string; note: string;
events: EntryId[]; entries: EntryId[];
skip: boolean; isNextDay: boolean;
targetDuration: MaybeNumber;
colour: string; colour: string;
custom: EntryCustomFields; custom: EntryCustomFields;
// !==== RUNTIME METADATA ====! // // !==== RUNTIME METADATA ====! //
@@ -340,7 +340,7 @@ describe('getLastEvent', () => {
const testRundown = { const testRundown = {
entries: { entries: {
1: { id: '1', type: SupportedEntry.Event } as OntimeEvent, 1: { id: '1', type: SupportedEntry.Event } as OntimeEvent,
block: { id: 'block', type: SupportedEntry.Block, events: ['21', '22', '23'] } as OntimeBlock, block: { id: 'block', type: SupportedEntry.Block, entries: ['21', '22', '23'] } as OntimeBlock,
21: { id: '21', type: SupportedEntry.Event, parent: 'block' } as OntimeEvent, 21: { id: '21', type: SupportedEntry.Event, parent: 'block' } as OntimeEvent,
22: { id: '22', type: SupportedEntry.Event, parent: 'block' } as OntimeEvent, 22: { id: '22', type: SupportedEntry.Event, parent: 'block' } as OntimeEvent,
23: { id: '23', type: SupportedEntry.Event, parent: 'block' } as OntimeEvent, 23: { id: '23', type: SupportedEntry.Event, parent: 'block' } as OntimeEvent,