mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 07:29:08 +00:00
refactor: implement operations on nested events
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
|||||||
EntryId,
|
EntryId,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
MaybeString,
|
MaybeString,
|
||||||
|
OntimeBlock,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
Rundown,
|
Rundown,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
TimeStrategy,
|
TimeStrategy,
|
||||||
TransientEventPayload,
|
TransientEventPayload,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
|
||||||
|
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +72,7 @@ export const useEntryActions = () => {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _addEntryMutation = useMutation({
|
const _addEntryMutation = useMutation({
|
||||||
|
// TODO(v4): optimistic create entry
|
||||||
mutationFn: postAddEntry,
|
mutationFn: postAddEntry,
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
@@ -83,7 +85,7 @@ export const useEntryActions = () => {
|
|||||||
*/
|
*/
|
||||||
const addEntry = useCallback(
|
const addEntry = useCallback(
|
||||||
async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
|
async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
|
||||||
const newEntry: TransientEventPayload = { ...entry };
|
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
|
||||||
|
|
||||||
// ************* CHECK OPTIONS specific to events
|
// ************* CHECK OPTIONS specific to events
|
||||||
if (isOntimeEvent(newEntry)) {
|
if (isOntimeEvent(newEntry)) {
|
||||||
@@ -188,6 +190,7 @@ export const useEntryActions = () => {
|
|||||||
id: previousData.id,
|
id: previousData.id,
|
||||||
title: previousData.title,
|
title: previousData.title,
|
||||||
order: previousData.order,
|
order: previousData.order,
|
||||||
|
flatOrder: previousData.flatOrder,
|
||||||
entries: newRundown,
|
entries: newRundown,
|
||||||
revision: -1,
|
revision: -1,
|
||||||
});
|
});
|
||||||
@@ -355,6 +358,7 @@ export const useEntryActions = () => {
|
|||||||
id: previousRundown.id,
|
id: previousRundown.id,
|
||||||
title: previousRundown.title,
|
title: previousRundown.title,
|
||||||
order: previousRundown.order,
|
order: previousRundown.order,
|
||||||
|
flatOrder: previousRundown.flatOrder,
|
||||||
entries: newRundown,
|
entries: newRundown,
|
||||||
revision: -1,
|
revision: -1,
|
||||||
});
|
});
|
||||||
@@ -399,17 +403,14 @@ export const useEntryActions = () => {
|
|||||||
|
|
||||||
if (previousData) {
|
if (previousData) {
|
||||||
// optimistically update object
|
// optimistically update object
|
||||||
const newOrder = previousData.order.filter((id) => !entryIds.includes(id));
|
const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData);
|
||||||
const newRundown = { ...previousData.entries };
|
|
||||||
for (const eventId of entryIds) {
|
|
||||||
delete newRundown[eventId];
|
|
||||||
}
|
|
||||||
|
|
||||||
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||||
id: previousData.id,
|
id: previousData.id,
|
||||||
title: previousData.title,
|
title: previousData.title,
|
||||||
order: newOrder,
|
order,
|
||||||
entries: newRundown,
|
flatOrder,
|
||||||
|
entries,
|
||||||
revision: -1,
|
revision: -1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -462,8 +463,9 @@ export const useEntryActions = () => {
|
|||||||
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||||
id: previousData?.id ?? 'default',
|
id: previousData?.id ?? 'default',
|
||||||
title: previousData?.title ?? '',
|
title: previousData?.title ?? '',
|
||||||
entries: {},
|
|
||||||
order: [],
|
order: [],
|
||||||
|
flatOrder: [],
|
||||||
|
entries: {},
|
||||||
revision: -1,
|
revision: -1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -542,6 +544,7 @@ export const useEntryActions = () => {
|
|||||||
id: previousData.id,
|
id: previousData.id,
|
||||||
title: previousData.title,
|
title: previousData.title,
|
||||||
order: newOrder,
|
order: newOrder,
|
||||||
|
flatOrder: previousData.flatOrder,
|
||||||
entries: previousData.entries,
|
entries: previousData.entries,
|
||||||
revision: -1,
|
revision: -1,
|
||||||
});
|
});
|
||||||
@@ -613,6 +616,7 @@ export const useEntryActions = () => {
|
|||||||
id: previousData.id,
|
id: previousData.id,
|
||||||
title: previousData.title,
|
title: previousData.title,
|
||||||
order: previousData.order,
|
order: previousData.order,
|
||||||
|
flatOrder: previousData.flatOrder,
|
||||||
entries: newRundown,
|
entries: newRundown,
|
||||||
revision: -1,
|
revision: -1,
|
||||||
});
|
});
|
||||||
@@ -662,3 +666,32 @@ export const useEntryActions = () => {
|
|||||||
updateCustomField,
|
updateCustomField,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to optimistically delete entries from client cache
|
||||||
|
*/
|
||||||
|
function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
|
||||||
|
const entries = { ...rundown.entries };
|
||||||
|
let order = [...rundown.order];
|
||||||
|
let flatOrder = [...rundown.flatOrder];
|
||||||
|
|
||||||
|
for (let i = 0; i < entryIds.length; i++) {
|
||||||
|
const entry = entries[entryIds[i]];
|
||||||
|
deleteEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteEntry(entry: OntimeEntry) {
|
||||||
|
if (isOntimeEvent(entry) && entry.parent) {
|
||||||
|
const parent = entries[entry.parent] as OntimeBlock;
|
||||||
|
parent.events = parent.events.filter((event) => event !== entry.id);
|
||||||
|
parent.numEvents -= 1;
|
||||||
|
} else {
|
||||||
|
order = order.filter((id) => id !== entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete entries[entry.id];
|
||||||
|
flatOrder = flatOrder.filter((id) => id !== entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entries, order, flatOrder };
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type Rundown,
|
type Rundown,
|
||||||
isOntimeBlock,
|
isOntimeBlock,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
|
OntimeEntry,
|
||||||
Playback,
|
Playback,
|
||||||
SupportedEvent,
|
SupportedEvent,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
@@ -93,7 +94,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const insertAtId = useCallback(
|
const insertAtId = useCallback(
|
||||||
(type: SupportedEvent, id: MaybeString, above = false) => {
|
(patch: Partial<OntimeEntry> & { type: SupportedEvent }, id: MaybeString, above = false) => {
|
||||||
const options: EventOptions =
|
const options: EventOptions =
|
||||||
id === null
|
id === null
|
||||||
? {}
|
? {}
|
||||||
@@ -102,17 +103,10 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
before: above ? id : undefined,
|
before: above ? id : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type === SupportedEvent.Event) {
|
if (!above && id) {
|
||||||
const newEvent = {
|
options.lastEventId = id;
|
||||||
type: SupportedEvent.Event,
|
|
||||||
};
|
|
||||||
if (!above && id) {
|
|
||||||
options.lastEventId = id;
|
|
||||||
}
|
|
||||||
addEntry(newEvent, options);
|
|
||||||
} else {
|
|
||||||
addEntry({ type }, options);
|
|
||||||
}
|
}
|
||||||
|
addEntry(patch, options);
|
||||||
},
|
},
|
||||||
[addEntry],
|
[addEntry],
|
||||||
);
|
);
|
||||||
@@ -208,14 +202,14 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
|
|
||||||
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
||||||
|
|
||||||
['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }],
|
['alt + E', () => insertAtId({ type: SupportedEvent.Event }, cursor), { preventDefault: true }],
|
||||||
['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }],
|
['alt + shift + E', () => insertAtId({ type: SupportedEvent.Event }, cursor, true), { preventDefault: true }],
|
||||||
|
|
||||||
['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }],
|
['alt + B', () => insertAtId({ type: SupportedEvent.Block }, cursor), { preventDefault: true }],
|
||||||
['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }],
|
['alt + shift + B', () => insertAtId({ type: SupportedEvent.Block }, cursor, true), { preventDefault: true }],
|
||||||
|
|
||||||
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }],
|
['alt + D', () => insertAtId({ type: SupportedEvent.Delay }, cursor), { preventDefault: true }],
|
||||||
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
|
['alt + shift + D', () => insertAtId({ type: SupportedEvent.Delay }, cursor, true), { preventDefault: true }],
|
||||||
|
|
||||||
['mod + C', () => setEntryCopyId(cursor)],
|
['mod + C', () => setEntryCopyId(cursor)],
|
||||||
['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
|
['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
|
||||||
@@ -260,7 +254,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (statefulEntries.length < 1) {
|
if (statefulEntries.length < 1) {
|
||||||
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
|
return <RundownEmpty handleAddNew={() => insertAtId({ type: SupportedEvent.Event }, cursor)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. gather presentation options
|
// 1. gather presentation options
|
||||||
@@ -292,15 +286,21 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
return (
|
return (
|
||||||
<Fragment key={entry.id}>
|
<Fragment key={entry.id}>
|
||||||
{isEditMode && (hasCursor || isFirst) && (
|
{isEditMode && (hasCursor || isFirst) && (
|
||||||
<QuickAddBlock showBlocks previousEventId={rundownMeta.previousEntryId} />
|
<QuickAddBlock previousEventId={rundownMeta.previousEntryId} parentBlock={null} />
|
||||||
)}
|
)}
|
||||||
{isOntimeBlock(entry) ? (
|
{isOntimeBlock(entry) ? (
|
||||||
<BlockBlock data={entry} hasCursor={hasCursor}>
|
<BlockBlock data={entry} hasCursor={hasCursor}>
|
||||||
{entry.events.length === 0 && (
|
{entry.events.length === 0 && (
|
||||||
<BlockEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />
|
<BlockEmpty
|
||||||
|
handleAddNew={() => insertAtId({ type: SupportedEvent.Event, parent: entry.id }, entry.id)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{entry.events.map((eventId, nestedIndex) => {
|
{entry.events.map((eventId, nestedIndex) => {
|
||||||
const nestedEntry = entries[eventId];
|
const nestedEntry = entries[eventId];
|
||||||
|
if (!nestedEntry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const nestedRundownMeta = process(nestedEntry);
|
const nestedRundownMeta = process(nestedEntry);
|
||||||
const isFirstInGroup = nestedIndex === 0;
|
const isFirstInGroup = nestedIndex === 0;
|
||||||
const isLastInGroup = nestedIndex === entry.events.length - 1;
|
const isLastInGroup = nestedIndex === entry.events.length - 1;
|
||||||
@@ -312,7 +312,10 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
return (
|
return (
|
||||||
<Fragment key={nestedEntry.id}>
|
<Fragment key={nestedEntry.id}>
|
||||||
{isEditMode && (hasNestedCursor || isFirstInGroup) && (
|
{isEditMode && (hasNestedCursor || isFirstInGroup) && (
|
||||||
<QuickAddBlock previousEventId={rundownMeta.previousEntryId} />
|
<QuickAddBlock
|
||||||
|
parentBlock={entry.id}
|
||||||
|
previousEventId={nestedRundownMeta.previousEntryId}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -342,7 +345,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isEditMode && (hasNestedCursor || isLastInGroup) && (
|
{isEditMode && (hasNestedCursor || isLastInGroup) && (
|
||||||
<QuickAddBlock previousEventId={entry.id} />
|
<QuickAddBlock parentBlock={entry.id} previousEventId={nestedEntry.id} />
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
@@ -371,7 +374,9 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isEditMode && (hasCursor || isLast) && <QuickAddBlock showBlocks previousEventId={entry.id} />}
|
{isEditMode && (hasCursor || isLast) && (
|
||||||
|
<QuickAddBlock previousEventId={entry.id} parentBlock={null} />
|
||||||
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
font-size: calc(1rem - 3px);
|
font-size: calc(1rem - 3px);
|
||||||
|
margin-left: calc(2em + 0.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quickBtn {
|
.quickBtn {
|
||||||
|
|||||||
@@ -1,74 +1,69 @@
|
|||||||
import { memo, useCallback, useRef } from 'react';
|
import { memo, useRef } from 'react';
|
||||||
import { IoAdd } from 'react-icons/io5';
|
import { IoAdd } from 'react-icons/io5';
|
||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
import { MaybeString, SupportedEvent } from 'ontime-types';
|
import { MaybeString, SupportedEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||||
import { useEmitLog } from '../../../common/stores/logger';
|
|
||||||
|
|
||||||
import style from './QuickAddBlock.module.scss';
|
import style from './QuickAddBlock.module.scss';
|
||||||
|
|
||||||
interface QuickAddBlockProps {
|
interface QuickAddBlockProps {
|
||||||
previousEventId: MaybeString;
|
previousEventId: MaybeString;
|
||||||
showBlocks?: boolean;
|
parentBlock: MaybeString;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(QuickAddBlock);
|
export default memo(QuickAddBlock);
|
||||||
|
|
||||||
function QuickAddBlock(props: QuickAddBlockProps) {
|
function QuickAddBlock(props: QuickAddBlockProps) {
|
||||||
const { previousEventId, showBlocks } = props;
|
const { previousEventId, parentBlock } = props;
|
||||||
const { addEntry } = useEntryActions();
|
const { addEntry } = useEntryActions();
|
||||||
const { emitError } = useEmitLog();
|
|
||||||
|
|
||||||
const doLinkPrevious = useRef<HTMLInputElement | null>(null);
|
const doLinkPrevious = useRef<HTMLInputElement | null>(null);
|
||||||
const doPublic = useRef<HTMLInputElement | null>(null);
|
const doPublic = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const handleCreateEvent = useCallback(
|
const addEvent = () => {
|
||||||
(eventType: SupportedEvent) => {
|
addEntry(
|
||||||
switch (eventType) {
|
{
|
||||||
case 'event': {
|
type: SupportedEvent.Event,
|
||||||
const defaultPublic = doPublic?.current?.checked;
|
parent: parentBlock ?? null,
|
||||||
const linkPrevious = doLinkPrevious?.current?.checked;
|
},
|
||||||
|
{
|
||||||
|
after: previousEventId,
|
||||||
|
defaultPublic: doPublic?.current?.checked,
|
||||||
|
lastEventId: previousEventId,
|
||||||
|
linkPrevious: doLinkPrevious?.current?.checked,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const newEvent = { type: SupportedEvent.Event };
|
const addDelay = () => {
|
||||||
const options = {
|
addEntry(
|
||||||
after: previousEventId,
|
// TODO(v4): add delays to blocks
|
||||||
defaultPublic,
|
{ type: SupportedEvent.Delay },
|
||||||
lastEventId: previousEventId,
|
{
|
||||||
linkPrevious,
|
lastEventId: previousEventId,
|
||||||
};
|
after: previousEventId,
|
||||||
addEntry(newEvent, options);
|
},
|
||||||
break;
|
);
|
||||||
}
|
};
|
||||||
case 'delay': {
|
|
||||||
const options = {
|
const addBlock = () => {
|
||||||
lastEventId: previousEventId,
|
if (parentBlock !== null) {
|
||||||
after: previousEventId,
|
return;
|
||||||
};
|
}
|
||||||
addEntry({ type: SupportedEvent.Delay }, options);
|
addEntry(
|
||||||
break;
|
{ type: SupportedEvent.Block },
|
||||||
}
|
{
|
||||||
case 'block': {
|
lastEventId: previousEventId,
|
||||||
const options = {
|
after: previousEventId,
|
||||||
lastEventId: previousEventId,
|
},
|
||||||
after: previousEventId,
|
);
|
||||||
};
|
};
|
||||||
addEntry({ type: SupportedEvent.Block }, options);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
emitError(`Cannot create unknown event type: ${eventType}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[previousEventId, addEntry, emitError],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.quickAdd}>
|
<div className={style.quickAdd}>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleCreateEvent(SupportedEvent.Event)}
|
onClick={addEvent}
|
||||||
size='xs'
|
size='xs'
|
||||||
variant='ontime-subtle-white'
|
variant='ontime-subtle-white'
|
||||||
className={style.quickBtn}
|
className={style.quickBtn}
|
||||||
@@ -78,7 +73,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
|||||||
Event
|
Event
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleCreateEvent(SupportedEvent.Delay)}
|
onClick={addDelay}
|
||||||
size='xs'
|
size='xs'
|
||||||
variant='ontime-subtle-white'
|
variant='ontime-subtle-white'
|
||||||
className={style.quickBtn}
|
className={style.quickBtn}
|
||||||
@@ -87,9 +82,9 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
|||||||
>
|
>
|
||||||
Delay
|
Delay
|
||||||
</Button>
|
</Button>
|
||||||
{showBlocks && (
|
{parentBlock === null && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleCreateEvent(SupportedEvent.Block)}
|
onClick={addBlock}
|
||||||
size='xs'
|
size='xs'
|
||||||
variant='ontime-subtle-white'
|
variant='ontime-subtle-white'
|
||||||
className={style.quickBtn}
|
className={style.quickBtn}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function makeRundownMetadata(selectedEventId: MaybeString) {
|
|||||||
isNext: false,
|
isNext: false,
|
||||||
isNextDay: false,
|
isNextDay: false,
|
||||||
totalGap: 0,
|
totalGap: 0,
|
||||||
isLinkedToLoaded: true,
|
isLinkedToLoaded: false,
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ function processEntry(
|
|||||||
processedData.isLoaded = false;
|
processedData.isLoaded = false;
|
||||||
processedData.previousEntryId = processedData.thisId;
|
processedData.previousEntryId = processedData.thisId;
|
||||||
processedData.thisId = entry.id;
|
processedData.thisId = entry.id;
|
||||||
|
processedData.previousEvent = processedData.latestEvent;
|
||||||
|
|
||||||
if (entry.id === selectedEventId) {
|
if (entry.id === selectedEventId) {
|
||||||
processedData.isLoaded = true;
|
processedData.isLoaded = true;
|
||||||
@@ -65,19 +66,18 @@ function processEntry(
|
|||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeEvent(entry)) {
|
||||||
// event indexes are 1 based in UI
|
// event indexes are 1 based in UI
|
||||||
processedData.eventIndex += 1;
|
processedData.eventIndex += 1;
|
||||||
processedData.previousEvent = processedData.latestEvent;
|
|
||||||
|
|
||||||
if (isPlayableEvent(entry)) {
|
if (isPlayableEvent(entry)) {
|
||||||
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
||||||
|
processedData.totalGap += entry.gap;
|
||||||
|
|
||||||
if (!processedData.isPast) {
|
if (!processedData.isPast && !processedData.isLoaded) {
|
||||||
processedData.totalGap += entry.gap;
|
|
||||||
/**
|
/**
|
||||||
* isLinkToLoaded is a chain value that we maintain until we find an unlinked event
|
* isLinkToLoaded is a chain value that we maintain until we
|
||||||
* or we find a countToEnd event
|
* a) find an unlinked event
|
||||||
|
* b) find a countToEnd event
|
||||||
*/
|
*/
|
||||||
processedData.isLinkedToLoaded =
|
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
|
||||||
processedData.isLinkedToLoaded && entry.linkStart && !processedData.previousEvent?.countToEnd;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNewLatest(entry, processedData.previousEvent)) {
|
if (isNewLatest(entry, processedData.previousEvent)) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
CustomFields,
|
CustomFields,
|
||||||
LogOrigin,
|
|
||||||
OntimeBlock,
|
OntimeBlock,
|
||||||
OntimeDelay,
|
OntimeDelay,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
@@ -17,12 +16,12 @@ import { getCueCandidate } from 'ontime-utils';
|
|||||||
|
|
||||||
import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
|
import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
|
||||||
import { sendRefetch } from '../../adapters/websocketAux.js';
|
import { sendRefetch } from '../../adapters/websocketAux.js';
|
||||||
import { logger } from '../../classes/Logger.js';
|
|
||||||
import { createEvent } from '../../utils/parser.js';
|
import { createEvent } from '../../utils/parser.js';
|
||||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||||
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
||||||
|
|
||||||
import * as cache from './rundownCache.js';
|
import * as cache from './rundownCache.js';
|
||||||
|
import { getInsertionPosition } from './rundownUtils.js';
|
||||||
|
|
||||||
type CompleteEntry<T> =
|
type CompleteEntry<T> =
|
||||||
T extends Partial<OntimeEvent>
|
T extends Partial<OntimeEvent>
|
||||||
@@ -40,10 +39,6 @@ function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | P
|
|||||||
eventData: T,
|
eventData: T,
|
||||||
afterId?: string,
|
afterId?: string,
|
||||||
): CompleteEntry<T> {
|
): CompleteEntry<T> {
|
||||||
// TODO: could we keep the UI ID to avoid the flash on create?
|
|
||||||
// we discard any UI provided IDs and add our own
|
|
||||||
const id = cache.getUniqueId();
|
|
||||||
|
|
||||||
if (isOntimeEvent(eventData)) {
|
if (isOntimeEvent(eventData)) {
|
||||||
const currentRundown = cache.getCurrentRundown();
|
const currentRundown = cache.getCurrentRundown();
|
||||||
return createEvent(
|
return createEvent(
|
||||||
@@ -52,10 +47,13 @@ function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | P
|
|||||||
) as CompleteEntry<T>;
|
) as CompleteEntry<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const id = eventData.id || cache.getUniqueId();
|
||||||
|
|
||||||
if (isOntimeDelay(eventData)) {
|
if (isOntimeDelay(eventData)) {
|
||||||
return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry<T>;
|
return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(v4): allow user to provide a larger patch of the block entry
|
||||||
if (isOntimeBlock(eventData)) {
|
if (isOntimeBlock(eventData)) {
|
||||||
return { ...blockDef, title: eventData?.title ?? '', id } as CompleteEntry<T>;
|
return { ...blockDef, title: eventData?.title ?? '', id } as CompleteEntry<T>;
|
||||||
}
|
}
|
||||||
@@ -67,41 +65,46 @@ function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | P
|
|||||||
* creates a new event with given data
|
* creates a new event with given data
|
||||||
*/
|
*/
|
||||||
export async function addEvent(eventData: EventPostPayload): Promise<OntimeEntry> {
|
export async function addEvent(eventData: EventPostPayload): Promise<OntimeEntry> {
|
||||||
// if the user didnt provide an index, we add the event to start
|
// 1. we allow the user to provide an ID, but make sure it is unique
|
||||||
let atIndex = 0;
|
if (eventData?.id && cache.hasId(eventData.id)) {
|
||||||
let afterId: string | undefined = eventData?.after;
|
throw new Error(`Event with ID ${eventData.id} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
if (afterId) {
|
// 2. if the user provides a parent (inside a group), we make sure it exists
|
||||||
const previousIndex = cache.getIndexOf(afterId);
|
let parent: EntryId | undefined;
|
||||||
if (previousIndex < 0) {
|
if ('parent' in eventData && eventData.parent != null) {
|
||||||
logger.warning(LogOrigin.Server, `Could not find event with id ${afterId}`);
|
if (!cache.hasId(eventData.parent)) {
|
||||||
} else {
|
throw new Error(`Parent event with ID ${eventData.parent} not found`);
|
||||||
atIndex = previousIndex + 1;
|
|
||||||
}
|
}
|
||||||
} else if (eventData?.before !== undefined) {
|
parent = eventData.parent;
|
||||||
const previousIndex = cache.getIndexOf(eventData.before);
|
}
|
||||||
if (previousIndex < 0) {
|
|
||||||
logger.warning(LogOrigin.Server, `Could not find event with id ${eventData.before}`);
|
// 3. if the user provides an after or before ID, we make sure it exists
|
||||||
} else {
|
if (eventData?.after !== undefined) {
|
||||||
atIndex = previousIndex;
|
if (!cache.hasId(eventData.after)) {
|
||||||
if (previousIndex > 0) {
|
throw new Error(`Event with ID ${eventData.after} not found`);
|
||||||
afterId = cache.getIdOf(atIndex - 1);
|
}
|
||||||
}
|
}
|
||||||
|
if (eventData?.before !== undefined) {
|
||||||
|
if (!cache.hasId(eventData.before)) {
|
||||||
|
throw new Error(`Event with ID ${eventData.before} not found`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// generate a fully formed event from the patch
|
const { afterId, atIndex } = getInsertionPosition(parent, eventData?.after, eventData?.before);
|
||||||
const eventToAdd = generateEvent(eventData, afterId);
|
|
||||||
|
// generate a fully formed entry from the patch
|
||||||
|
const sanitisedEntry = generateEvent(eventData, afterId);
|
||||||
|
|
||||||
// modify rundown
|
// modify rundown
|
||||||
const scopedMutation = cache.mutateCache(cache.add);
|
const scopedMutation = cache.mutateCache(cache.add);
|
||||||
const { newEvent } = await scopedMutation({ atIndex, event: eventToAdd });
|
const { newEvent } = await scopedMutation({ atIndex, parent, entry: sanitisedEntry });
|
||||||
|
|
||||||
// notify runtime that rundown has changed
|
// notify runtime that rundown has changed
|
||||||
updateRuntimeOnChange();
|
updateRuntimeOnChange();
|
||||||
|
|
||||||
// notify timer and external services of change
|
// notify timer and external services of change
|
||||||
notifyChanges({ timer: [eventToAdd.id], external: true });
|
notifyChanges({ timer: [sanitisedEntry.id], external: true });
|
||||||
|
|
||||||
// we know this mutation returns an OntimeEntry
|
// we know this mutation returns an OntimeEntry
|
||||||
return newEvent as OntimeEntry;
|
return newEvent as OntimeEntry;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ let currentRundown: Rundown = {
|
|||||||
id: '',
|
id: '',
|
||||||
title: '',
|
title: '',
|
||||||
order: [],
|
order: [],
|
||||||
|
flatOrder: [],
|
||||||
entries: {},
|
entries: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
};
|
};
|
||||||
@@ -101,6 +102,9 @@ export function generate(
|
|||||||
// we assign a reference to the current entry, this will be mutated in place
|
// we assign a reference to the current entry, this will be mutated in place
|
||||||
const currentEntryId = initialRundown.order[i];
|
const currentEntryId = initialRundown.order[i];
|
||||||
const currentEntry = initialRundown.entries[currentEntryId];
|
const currentEntry = initialRundown.entries[currentEntryId];
|
||||||
|
if (!currentEntry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const { processedEntry } = process(currentEntry, null);
|
const { processedEntry } = process(currentEntry, null);
|
||||||
|
|
||||||
// if the event is a block, we process the nested entries
|
// if the event is a block, we process the nested entries
|
||||||
@@ -115,6 +119,10 @@ export function generate(
|
|||||||
for (let i = 0; i < processedEntry.events.length; i++) {
|
for (let i = 0; i < processedEntry.events.length; i++) {
|
||||||
const nestedEntryId = processedEntry.events[i];
|
const nestedEntryId = processedEntry.events[i];
|
||||||
const nestedEntry = initialRundown.entries[nestedEntryId];
|
const nestedEntry = initialRundown.entries[nestedEntryId];
|
||||||
|
|
||||||
|
if (!nestedEntry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process(
|
const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process(
|
||||||
nestedEntry,
|
nestedEntry,
|
||||||
processedEntry.id,
|
processedEntry.id,
|
||||||
@@ -161,14 +169,22 @@ export function updateCache() {
|
|||||||
|
|
||||||
// update the cache values
|
// update the cache values
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||||
const { entries, order, previousEvent, latestEvent, ...metadata } = processedData;
|
const { previousEvent, latestEvent, ...metadata } = processedData;
|
||||||
currentRundown.entries = entries;
|
currentRundown.entries = metadata.entries;
|
||||||
currentRundown.order = order;
|
currentRundown.order = metadata.order;
|
||||||
|
currentRundown.flatOrder = metadata.flatEventOrder;
|
||||||
rundownMetadata = metadata;
|
rundownMetadata = metadata;
|
||||||
clearIsStale();
|
clearIsStale();
|
||||||
customFieldChangelog = {};
|
customFieldChangelog = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a given ID is exists in the current rundown
|
||||||
|
*/
|
||||||
|
export function hasId(id: EntryId): boolean {
|
||||||
|
return Object.hasOwn(currentRundown.entries, id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns an ID guaranteed to be unique */
|
/** Returns an ID guaranteed to be unique */
|
||||||
export function getUniqueId(): string {
|
export function getUniqueId(): string {
|
||||||
if (isStale) {
|
if (isStale) {
|
||||||
@@ -177,19 +193,19 @@ export function getUniqueId(): string {
|
|||||||
let id = '';
|
let id = '';
|
||||||
do {
|
do {
|
||||||
id = generateId();
|
id = generateId();
|
||||||
} while (Object.hasOwn(currentRundown.entries, id));
|
} while (hasId(id));
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns index of an event with a given id */
|
/** Returns index of an entry with a given id */
|
||||||
export function getIndexOf(eventId: EntryId) {
|
export function getIndexOf(entryId: EntryId) {
|
||||||
if (isStale) {
|
if (isStale) {
|
||||||
updateCache();
|
updateCache();
|
||||||
}
|
}
|
||||||
return currentRundown.order.indexOf(eventId);
|
return currentRundown.order.indexOf(entryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns id of an event at a given index */
|
/** Returns id of an entry at a given index */
|
||||||
export function getIdOf(index: number) {
|
export function getIdOf(index: number) {
|
||||||
if (isStale) {
|
if (isStale) {
|
||||||
updateCache();
|
updateCache();
|
||||||
@@ -304,34 +320,60 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
|
|||||||
return scopedMutation;
|
return scopedMutation;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddArgs = MutationParams<{ atIndex: number; event: OntimeEntry }>;
|
type AddArgs = MutationParams<{ atIndex: number; parent?: EntryId; entry: OntimeEntry }>;
|
||||||
/**
|
/**
|
||||||
* Add entry to rundown
|
* Add entry to rundown
|
||||||
*/
|
*/
|
||||||
export function add({ rundown, atIndex, event }: AddArgs): Required<MutatingReturn> {
|
export function add({ rundown, atIndex, parent, entry }: AddArgs): Required<MutatingReturn> {
|
||||||
const newEvent: OntimeEntry = { ...event };
|
const newEntry: OntimeEntry = { ...entry };
|
||||||
|
|
||||||
|
rundown.entries[newEntry.id] = newEntry;
|
||||||
|
|
||||||
|
if (parent) {
|
||||||
|
const parentBlock = rundown.entries[parent] as OntimeBlock;
|
||||||
|
parentBlock.events = insertAtIndex(atIndex, newEntry.id, parentBlock.events);
|
||||||
|
} else {
|
||||||
|
rundown.order = insertAtIndex(atIndex, newEntry.id, rundown.order);
|
||||||
|
}
|
||||||
|
|
||||||
rundown.entries[newEvent.id] = newEvent;
|
|
||||||
rundown.order = insertAtIndex(atIndex, newEvent.id, rundown.order);
|
|
||||||
setIsStale();
|
setIsStale();
|
||||||
return { newRundown: rundown, newEvent, didMutate: true };
|
return { newRundown: rundown, newEvent: newEntry, didMutate: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
type RemoveArgs = MutationParams<{ eventIds: EntryId[] }>;
|
type RemoveArgs = MutationParams<{ eventIds: EntryId[] }>;
|
||||||
/**
|
/**
|
||||||
* Remove entry to rundown
|
* Remove entries in a rundown
|
||||||
*/
|
*/
|
||||||
export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn {
|
export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn {
|
||||||
const previousLength = rundown.order.length;
|
let didMutate = false;
|
||||||
rundown.order = rundown.order.filter((id) => !eventIds.includes(id));
|
|
||||||
for (const id of eventIds) {
|
for (let i = 0; i < eventIds.length; i++) {
|
||||||
delete rundown.entries[id];
|
const entry = rundown.entries[eventIds[i]];
|
||||||
|
if (isOntimeEvent(entry) && entry.parent) {
|
||||||
|
const parentBlock = rundown.entries[entry.parent] as OntimeBlock;
|
||||||
|
edit({
|
||||||
|
rundown,
|
||||||
|
eventId: entry.parent,
|
||||||
|
patch: {
|
||||||
|
events: parentBlock.events.filter((id) => id !== eventIds[i]),
|
||||||
|
numEvents: parentBlock.events.length - 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
parentBlock.events = parentBlock.events.filter((id) => id !== entry.id);
|
||||||
|
} else {
|
||||||
|
rundown.order = rundown.order.filter((id) => id !== eventIds[i]);
|
||||||
|
}
|
||||||
|
didMutate = true;
|
||||||
|
delete rundown.entries[eventIds[i]];
|
||||||
}
|
}
|
||||||
const didMutate = rundown.order.length !== previousLength;
|
|
||||||
if (didMutate) setIsStale();
|
if (didMutate) setIsStale();
|
||||||
return { newRundown: rundown, didMutate };
|
return { newRundown: rundown, didMutate };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all entries of a rundown
|
||||||
|
*/
|
||||||
export function removeAll(): MutatingReturn {
|
export function removeAll(): MutatingReturn {
|
||||||
setIsStale();
|
setIsStale();
|
||||||
return {
|
return {
|
||||||
@@ -339,6 +381,7 @@ export function removeAll(): MutatingReturn {
|
|||||||
id: '',
|
id: '',
|
||||||
title: '',
|
title: '',
|
||||||
order: [],
|
order: [],
|
||||||
|
flatOrder: [],
|
||||||
entries: {},
|
entries: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
},
|
},
|
||||||
@@ -441,6 +484,7 @@ export function reorder({ rundown, eventId, from, to }: ReorderArgs): Required<M
|
|||||||
type ApplyDelayArgs = MutationParams<{ delayId: EntryId }>;
|
type ApplyDelayArgs = MutationParams<{ delayId: EntryId }>;
|
||||||
/**
|
/**
|
||||||
* Apply a delay
|
* Apply a delay
|
||||||
|
* Mutates the given rundown
|
||||||
*/
|
*/
|
||||||
export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn {
|
export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn {
|
||||||
apply(delayId, rundown);
|
apply(delayId, rundown);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
EntryId,
|
EntryId,
|
||||||
RundownEntries,
|
RundownEntries,
|
||||||
ProjectRundowns,
|
ProjectRundowns,
|
||||||
|
OntimeBlock,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
import * as cache from './rundownCache.js';
|
import * as cache from './rundownCache.js';
|
||||||
@@ -173,3 +174,38 @@ export function getRundownOrThrow(rundowns: ProjectRundowns, rundownId: string):
|
|||||||
}
|
}
|
||||||
return rundowns[rundownId];
|
return rundowns[rundownId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getInsertionPosition(
|
||||||
|
parentId?: EntryId,
|
||||||
|
afterId?: EntryId,
|
||||||
|
beforeId?: EntryId,
|
||||||
|
): { atIndex: number; afterId: EntryId | undefined } {
|
||||||
|
if (afterId) {
|
||||||
|
const order = selectOrderList(parentId);
|
||||||
|
return {
|
||||||
|
atIndex: order.findIndex((id) => id === afterId) + 1,
|
||||||
|
afterId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (beforeId) {
|
||||||
|
const order = selectOrderList(parentId);
|
||||||
|
const atIndex = order.findIndex((id) => id === beforeId);
|
||||||
|
return {
|
||||||
|
atIndex,
|
||||||
|
afterId: order[atIndex - 1] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
atIndex: 0,
|
||||||
|
afterId: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
function selectOrderList(parentId?: EntryId) {
|
||||||
|
if (parentId) {
|
||||||
|
return (getEntryWithId(parentId) as OntimeBlock).events;
|
||||||
|
}
|
||||||
|
return cache.getEventOrder().order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export type OntimeEvent = OntimeBaseEvent & {
|
|||||||
timeDanger: number;
|
timeDanger: number;
|
||||||
custom: EntryCustomFields;
|
custom: EntryCustomFields;
|
||||||
triggers?: Trigger[];
|
triggers?: Trigger[];
|
||||||
// !==== RUNTIME METADATA ====! //
|
|
||||||
parent: EntryId | null;
|
parent: EntryId | null;
|
||||||
|
// !==== RUNTIME METADATA ====! //
|
||||||
revision: number;
|
revision: number;
|
||||||
delay: number; // calculated at runtime
|
delay: number; // calculated at runtime
|
||||||
dayOffset: number; // calculated at runtime
|
dayOffset: number; // calculated at runtime
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type Rundown = {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
order: EntryId[];
|
order: EntryId[];
|
||||||
|
flatOrder: EntryId[];
|
||||||
entries: RundownEntries;
|
entries: RundownEntries;
|
||||||
revision: number;
|
revision: number;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user