mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-15 04:13:47 +00:00
refactor: implement operations on nested events
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
EntryId,
|
||||
isOntimeEvent,
|
||||
MaybeString,
|
||||
OntimeBlock,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
Rundown,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
TimeStrategy,
|
||||
TransientEventPayload,
|
||||
} 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 {
|
||||
@@ -71,6 +72,7 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const _addEntryMutation = useMutation({
|
||||
// TODO(v4): optimistic create entry
|
||||
mutationFn: postAddEntry,
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
@@ -83,7 +85,7 @@ export const useEntryActions = () => {
|
||||
*/
|
||||
const addEntry = useCallback(
|
||||
async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
|
||||
const newEntry: TransientEventPayload = { ...entry };
|
||||
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
|
||||
|
||||
// ************* CHECK OPTIONS specific to events
|
||||
if (isOntimeEvent(newEntry)) {
|
||||
@@ -188,6 +190,7 @@ export const useEntryActions = () => {
|
||||
id: previousData.id,
|
||||
title: previousData.title,
|
||||
order: previousData.order,
|
||||
flatOrder: previousData.flatOrder,
|
||||
entries: newRundown,
|
||||
revision: -1,
|
||||
});
|
||||
@@ -355,6 +358,7 @@ export const useEntryActions = () => {
|
||||
id: previousRundown.id,
|
||||
title: previousRundown.title,
|
||||
order: previousRundown.order,
|
||||
flatOrder: previousRundown.flatOrder,
|
||||
entries: newRundown,
|
||||
revision: -1,
|
||||
});
|
||||
@@ -399,17 +403,14 @@ export const useEntryActions = () => {
|
||||
|
||||
if (previousData) {
|
||||
// optimistically update object
|
||||
const newOrder = previousData.order.filter((id) => !entryIds.includes(id));
|
||||
const newRundown = { ...previousData.entries };
|
||||
for (const eventId of entryIds) {
|
||||
delete newRundown[eventId];
|
||||
}
|
||||
const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData);
|
||||
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||
id: previousData.id,
|
||||
title: previousData.title,
|
||||
order: newOrder,
|
||||
entries: newRundown,
|
||||
order,
|
||||
flatOrder,
|
||||
entries,
|
||||
revision: -1,
|
||||
});
|
||||
}
|
||||
@@ -462,8 +463,9 @@ export const useEntryActions = () => {
|
||||
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||
id: previousData?.id ?? 'default',
|
||||
title: previousData?.title ?? '',
|
||||
entries: {},
|
||||
order: [],
|
||||
flatOrder: [],
|
||||
entries: {},
|
||||
revision: -1,
|
||||
});
|
||||
|
||||
@@ -542,6 +544,7 @@ export const useEntryActions = () => {
|
||||
id: previousData.id,
|
||||
title: previousData.title,
|
||||
order: newOrder,
|
||||
flatOrder: previousData.flatOrder,
|
||||
entries: previousData.entries,
|
||||
revision: -1,
|
||||
});
|
||||
@@ -613,6 +616,7 @@ export const useEntryActions = () => {
|
||||
id: previousData.id,
|
||||
title: previousData.title,
|
||||
order: previousData.order,
|
||||
flatOrder: previousData.flatOrder,
|
||||
entries: newRundown,
|
||||
revision: -1,
|
||||
});
|
||||
@@ -662,3 +666,32 @@ export const useEntryActions = () => {
|
||||
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,
|
||||
isOntimeBlock,
|
||||
isOntimeEvent,
|
||||
OntimeEntry,
|
||||
Playback,
|
||||
SupportedEvent,
|
||||
} from 'ontime-types';
|
||||
@@ -93,7 +94,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
);
|
||||
|
||||
const insertAtId = useCallback(
|
||||
(type: SupportedEvent, id: MaybeString, above = false) => {
|
||||
(patch: Partial<OntimeEntry> & { type: SupportedEvent }, id: MaybeString, above = false) => {
|
||||
const options: EventOptions =
|
||||
id === null
|
||||
? {}
|
||||
@@ -102,17 +103,10 @@ export default function Rundown({ data }: RundownProps) {
|
||||
before: above ? id : undefined,
|
||||
};
|
||||
|
||||
if (type === SupportedEvent.Event) {
|
||||
const newEvent = {
|
||||
type: SupportedEvent.Event,
|
||||
};
|
||||
if (!above && id) {
|
||||
options.lastEventId = id;
|
||||
}
|
||||
addEntry(newEvent, options);
|
||||
} else {
|
||||
addEntry({ type }, options);
|
||||
if (!above && id) {
|
||||
options.lastEventId = id;
|
||||
}
|
||||
addEntry(patch, options);
|
||||
},
|
||||
[addEntry],
|
||||
);
|
||||
@@ -208,14 +202,14 @@ export default function Rundown({ data }: RundownProps) {
|
||||
|
||||
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
||||
|
||||
['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }],
|
||||
['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }],
|
||||
['alt + E', () => insertAtId({ type: SupportedEvent.Event }, cursor), { preventDefault: true }],
|
||||
['alt + shift + E', () => insertAtId({ type: SupportedEvent.Event }, cursor, true), { preventDefault: true }],
|
||||
|
||||
['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }],
|
||||
['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }],
|
||||
['alt + B', () => insertAtId({ type: SupportedEvent.Block }, cursor), { preventDefault: true }],
|
||||
['alt + shift + B', () => insertAtId({ type: SupportedEvent.Block }, cursor, true), { preventDefault: true }],
|
||||
|
||||
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }],
|
||||
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
|
||||
['alt + D', () => insertAtId({ type: SupportedEvent.Delay }, cursor), { preventDefault: true }],
|
||||
['alt + shift + D', () => insertAtId({ type: SupportedEvent.Delay }, cursor, true), { preventDefault: true }],
|
||||
|
||||
['mod + C', () => setEntryCopyId(cursor)],
|
||||
['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
|
||||
@@ -260,7 +254,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
};
|
||||
|
||||
if (statefulEntries.length < 1) {
|
||||
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
|
||||
return <RundownEmpty handleAddNew={() => insertAtId({ type: SupportedEvent.Event }, cursor)} />;
|
||||
}
|
||||
|
||||
// 1. gather presentation options
|
||||
@@ -292,15 +286,21 @@ export default function Rundown({ data }: RundownProps) {
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
{isEditMode && (hasCursor || isFirst) && (
|
||||
<QuickAddBlock showBlocks previousEventId={rundownMeta.previousEntryId} />
|
||||
<QuickAddBlock previousEventId={rundownMeta.previousEntryId} parentBlock={null} />
|
||||
)}
|
||||
{isOntimeBlock(entry) ? (
|
||||
<BlockBlock data={entry} hasCursor={hasCursor}>
|
||||
{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) => {
|
||||
const nestedEntry = entries[eventId];
|
||||
if (!nestedEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestedRundownMeta = process(nestedEntry);
|
||||
const isFirstInGroup = nestedIndex === 0;
|
||||
const isLastInGroup = nestedIndex === entry.events.length - 1;
|
||||
@@ -312,7 +312,10 @@ export default function Rundown({ data }: RundownProps) {
|
||||
return (
|
||||
<Fragment key={nestedEntry.id}>
|
||||
{isEditMode && (hasNestedCursor || isFirstInGroup) && (
|
||||
<QuickAddBlock previousEventId={rundownMeta.previousEntryId} />
|
||||
<QuickAddBlock
|
||||
parentBlock={entry.id}
|
||||
previousEventId={nestedRundownMeta.previousEntryId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
@@ -342,7 +345,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
</div>
|
||||
</div>
|
||||
{isEditMode && (hasNestedCursor || isLastInGroup) && (
|
||||
<QuickAddBlock previousEventId={entry.id} />
|
||||
<QuickAddBlock parentBlock={entry.id} previousEventId={nestedEntry.id} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
@@ -371,7 +374,9 @@ export default function Rundown({ data }: RundownProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isEditMode && (hasCursor || isLast) && <QuickAddBlock showBlocks previousEventId={entry.id} />}
|
||||
{isEditMode && (hasCursor || isLast) && (
|
||||
<QuickAddBlock previousEventId={entry.id} parentBlock={null} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
margin: 0.25rem 0;
|
||||
font-size: calc(1rem - 3px);
|
||||
margin-left: calc(2em + 0.5rem);
|
||||
}
|
||||
|
||||
.quickBtn {
|
||||
|
||||
@@ -1,74 +1,69 @@
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { memo, useRef } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { MaybeString, SupportedEvent } from 'ontime-types';
|
||||
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import { useEmitLog } from '../../../common/stores/logger';
|
||||
|
||||
import style from './QuickAddBlock.module.scss';
|
||||
|
||||
interface QuickAddBlockProps {
|
||||
previousEventId: MaybeString;
|
||||
showBlocks?: boolean;
|
||||
parentBlock: MaybeString;
|
||||
}
|
||||
|
||||
export default memo(QuickAddBlock);
|
||||
|
||||
function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
const { previousEventId, showBlocks } = props;
|
||||
const { previousEventId, parentBlock } = props;
|
||||
const { addEntry } = useEntryActions();
|
||||
const { emitError } = useEmitLog();
|
||||
|
||||
const doLinkPrevious = useRef<HTMLInputElement | null>(null);
|
||||
const doPublic = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleCreateEvent = useCallback(
|
||||
(eventType: SupportedEvent) => {
|
||||
switch (eventType) {
|
||||
case 'event': {
|
||||
const defaultPublic = doPublic?.current?.checked;
|
||||
const linkPrevious = doLinkPrevious?.current?.checked;
|
||||
const addEvent = () => {
|
||||
addEntry(
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
parent: parentBlock ?? null,
|
||||
},
|
||||
{
|
||||
after: previousEventId,
|
||||
defaultPublic: doPublic?.current?.checked,
|
||||
lastEventId: previousEventId,
|
||||
linkPrevious: doLinkPrevious?.current?.checked,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const newEvent = { type: SupportedEvent.Event };
|
||||
const options = {
|
||||
after: previousEventId,
|
||||
defaultPublic,
|
||||
lastEventId: previousEventId,
|
||||
linkPrevious,
|
||||
};
|
||||
addEntry(newEvent, options);
|
||||
break;
|
||||
}
|
||||
case 'delay': {
|
||||
const options = {
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
};
|
||||
addEntry({ type: SupportedEvent.Delay }, options);
|
||||
break;
|
||||
}
|
||||
case 'block': {
|
||||
const options = {
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
};
|
||||
addEntry({ type: SupportedEvent.Block }, options);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
emitError(`Cannot create unknown event type: ${eventType}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[previousEventId, addEntry, emitError],
|
||||
);
|
||||
const addDelay = () => {
|
||||
addEntry(
|
||||
// TODO(v4): add delays to blocks
|
||||
{ type: SupportedEvent.Delay },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const addBlock = () => {
|
||||
if (parentBlock !== null) {
|
||||
return;
|
||||
}
|
||||
addEntry(
|
||||
{ type: SupportedEvent.Block },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.quickAdd}>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Event)}
|
||||
onClick={addEvent}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
@@ -78,7 +73,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
Event
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Delay)}
|
||||
onClick={addDelay}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
@@ -87,9 +82,9 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
>
|
||||
Delay
|
||||
</Button>
|
||||
{showBlocks && (
|
||||
{parentBlock === null && (
|
||||
<Button
|
||||
onClick={() => handleCreateEvent(SupportedEvent.Block)}
|
||||
onClick={addBlock}
|
||||
size='xs'
|
||||
variant='ontime-subtle-white'
|
||||
className={style.quickBtn}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function makeRundownMetadata(selectedEventId: MaybeString) {
|
||||
isNext: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: true,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
};
|
||||
|
||||
@@ -56,6 +56,7 @@ function processEntry(
|
||||
processedData.isLoaded = false;
|
||||
processedData.previousEntryId = processedData.thisId;
|
||||
processedData.thisId = entry.id;
|
||||
processedData.previousEvent = processedData.latestEvent;
|
||||
|
||||
if (entry.id === selectedEventId) {
|
||||
processedData.isLoaded = true;
|
||||
@@ -65,19 +66,18 @@ function processEntry(
|
||||
if (isOntimeEvent(entry)) {
|
||||
// event indexes are 1 based in UI
|
||||
processedData.eventIndex += 1;
|
||||
processedData.previousEvent = processedData.latestEvent;
|
||||
|
||||
if (isPlayableEvent(entry)) {
|
||||
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
||||
processedData.totalGap += entry.gap;
|
||||
|
||||
if (!processedData.isPast) {
|
||||
processedData.totalGap += entry.gap;
|
||||
if (!processedData.isPast && !processedData.isLoaded) {
|
||||
/**
|
||||
* isLinkToLoaded is a chain value that we maintain until we find an unlinked event
|
||||
* or we find a countToEnd event
|
||||
* isLinkToLoaded is a chain value that we maintain until we
|
||||
* 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)) {
|
||||
|
||||
Reference in New Issue
Block a user