mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 10:53:51 +00:00
refactor: extract rundown parsing
refactor: implement groups in editor
This commit is contained in:
committed by
Carlos Valente
parent
166be66ce3
commit
c616240db1
@@ -138,16 +138,14 @@ export const useEntryActions = () => {
|
||||
|
||||
// handle adding options that concern all event type
|
||||
if (options?.after) {
|
||||
// @ts-expect-error -- not sure how to type this, <after> is a transient property
|
||||
newEntry.after = options.after;
|
||||
(newEntry as TransientEventPayload).after = options.after;
|
||||
}
|
||||
if (options?.before) {
|
||||
// @ts-expect-error -- not sure how to type this, <before> is a transient property
|
||||
newEntry.before = options.before;
|
||||
(newEntry as TransientEventPayload).before = options.before;
|
||||
}
|
||||
|
||||
try {
|
||||
await _addEntryMutation.mutateAsync(newEntry as TransientEventPayload);
|
||||
await _addEntryMutation.mutateAsync(newEntry);
|
||||
} catch (error) {
|
||||
logAxiosError('Failed adding event', error);
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
|
||||
import style from './Empty.module.scss';
|
||||
|
||||
interface BlockEmptyProps {
|
||||
handleAddNew: () => void;
|
||||
}
|
||||
|
||||
export default function BlockEmpty(props: BlockEmptyProps) {
|
||||
const { handleAddNew } = props;
|
||||
|
||||
return (
|
||||
<div className={style.empty}>
|
||||
<Button size='sm' onClick={handleAddNew} variant='ontime-filled' leftIcon={<IoAdd />}>
|
||||
Create Event
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
|
||||
}
|
||||
|
||||
.entryIndex {
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { useHotkeys, useSessionStorage } from '@mantine/hooks';
|
||||
import {
|
||||
type EntryId,
|
||||
type MaybeString,
|
||||
type Rundown,
|
||||
isOntimeBlock,
|
||||
isOntimeEvent,
|
||||
OntimeBlock,
|
||||
OntimeEntry,
|
||||
Playback,
|
||||
SupportedEntry,
|
||||
@@ -30,9 +40,9 @@ import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
||||
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||
|
||||
import BlockBlock from './block-block/BlockBlock';
|
||||
import BlockEnd from './block-block/BlockEnd';
|
||||
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
||||
import BlockEmpty from './BlockEmpty';
|
||||
import { makeRundownMetadata } from './rundown.utils';
|
||||
import { makeRundownMetadata, makeSortableList } from './rundown.utils';
|
||||
import RundownEmpty from './RundownEmpty';
|
||||
import { useEventSelection } from './useEventSelection';
|
||||
|
||||
@@ -45,10 +55,16 @@ interface RundownProps {
|
||||
}
|
||||
|
||||
export default function Rundown({ data }: RundownProps) {
|
||||
const { order, entries } = data;
|
||||
const [statefulEntries, setStatefulEntries] = useState<EntryId[]>(order);
|
||||
|
||||
const { order, flatOrder, entries, id } = data;
|
||||
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
|
||||
const featureData = useRundownEditor();
|
||||
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(flatOrder, entries));
|
||||
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
||||
// we ensure that this is unique to the rundown
|
||||
key: `rundown.${id}-editor-collapsed-groups`,
|
||||
defaultValue: [],
|
||||
});
|
||||
|
||||
const { addEntry, reorderEntry, deleteEntry } = useEntryActions();
|
||||
|
||||
const { entryCopyId, setEntryCopyId } = useEntryCopy();
|
||||
@@ -221,11 +237,11 @@ export default function Rundown({ data }: RundownProps) {
|
||||
// we copy the state from the store here
|
||||
// to workaround async updates on the drag mutations
|
||||
useEffect(() => {
|
||||
setStatefulEntries(order);
|
||||
}, [order]);
|
||||
setSortableData(makeSortableList(flatOrder, entries));
|
||||
}, [flatOrder, entries]);
|
||||
|
||||
// in run mode, we follow selection
|
||||
useEffect(() => {
|
||||
// in run mode, we follow selection
|
||||
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
|
||||
return;
|
||||
}
|
||||
@@ -233,6 +249,36 @@ export default function Rundown({ data }: RundownProps) {
|
||||
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
|
||||
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
|
||||
|
||||
/**
|
||||
* Checks whether a block is collapsed
|
||||
*/
|
||||
const getIsCollapsed = useCallback(
|
||||
(blockId: EntryId): boolean => {
|
||||
return Boolean(collapsedGroups.find((id) => id === blockId));
|
||||
},
|
||||
[collapsedGroups],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles logic for collapsing groups
|
||||
*/
|
||||
const handleCollapseGroup = useCallback(
|
||||
(collapsed: boolean, groupId: EntryId) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const isCollapsed = getIsCollapsed(groupId);
|
||||
if (collapsed && !isCollapsed) {
|
||||
const newSet = new Set(prev).add(groupId);
|
||||
return [...newSet];
|
||||
}
|
||||
if (!collapsed && isCollapsed) {
|
||||
return [...prev].filter((id) => id !== groupId);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[getIsCollapsed, setCollapsedGroups],
|
||||
);
|
||||
|
||||
/**
|
||||
* On drag end, we reorder the events
|
||||
*/
|
||||
@@ -245,7 +291,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
const toIndex = over.data.current?.sortable.index;
|
||||
|
||||
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
|
||||
setStatefulEntries((currentEntries) => {
|
||||
setSortableData((currentEntries) => {
|
||||
return reorderArray(currentEntries, fromIndex, toIndex);
|
||||
});
|
||||
reorderEntry(String(active.id), fromIndex, toIndex);
|
||||
@@ -253,7 +299,36 @@ export default function Rundown({ data }: RundownProps) {
|
||||
}
|
||||
};
|
||||
|
||||
if (statefulEntries.length < 1) {
|
||||
/**
|
||||
* When we drag a block, we force collapse it
|
||||
* This avoids strange scenarios like dropping a block inside itself
|
||||
*/
|
||||
const collapseDraggedBlocks = (event: DragStartEvent) => {
|
||||
const isBlock = event.active.data.current?.type === 'block';
|
||||
if (isBlock) {
|
||||
handleCollapseGroup(true, event.active.id as EntryId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When we drag over a block, we expand it if it is collapsed
|
||||
*/
|
||||
const expandOverBlock = (event: DragOverEvent) => {
|
||||
// if we are dragging a block, the drop operation is invalid so we dont expand
|
||||
if (event.active.data.current?.type === 'block') {
|
||||
return;
|
||||
}
|
||||
if (event.over?.data.current?.type !== 'block') {
|
||||
return;
|
||||
}
|
||||
const blockId = event.over?.id as EntryId;
|
||||
const isCollapsed = getIsCollapsed(blockId);
|
||||
if (isCollapsed) {
|
||||
handleCollapseGroup(false, blockId);
|
||||
}
|
||||
};
|
||||
|
||||
if (sortableData.length < 1) {
|
||||
return <RundownEmpty handleAddNew={() => insertAtId({ type: SupportedEntry.Event }, cursor)} />;
|
||||
}
|
||||
|
||||
@@ -261,121 +336,130 @@ export default function Rundown({ data }: RundownProps) {
|
||||
const isEditMode = appMode === AppMode.Edit;
|
||||
|
||||
// 2. initialise rundown metadata
|
||||
const process = makeRundownMetadata(featureData?.selectedEventId);
|
||||
const { metadata, process } = makeRundownMetadata(featureData?.selectedEventId);
|
||||
// keep a single reference to the metadata which we override for every entry
|
||||
let rundownMetadata = metadata;
|
||||
|
||||
return (
|
||||
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
|
||||
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
|
||||
<DndContext
|
||||
onDragEnd={handleOnDragEnd}
|
||||
onDragStart={collapseDraggedBlocks}
|
||||
onDragOver={expandOverBlock}
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
>
|
||||
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
|
||||
<div className={style.list}>
|
||||
{statefulEntries.map((entryId, index) => {
|
||||
// we iterate through a stateful copy of order to make the operations smoother
|
||||
{sortableData.map((entryId, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === sortableData.length - 1;
|
||||
|
||||
// the entry might be a pseudo block-end which does not generate metadata and should not be processed
|
||||
if (entryId.startsWith('end-')) {
|
||||
const parentId = entryId.split('end-')[1];
|
||||
const isBlockCollapsed = getIsCollapsed(parentId);
|
||||
|
||||
if (isBlockCollapsed && isEditMode && isLast) {
|
||||
return <QuickAddBlock key={entryId} previousEventId={parentId} parentBlock={null} />;
|
||||
} else {
|
||||
const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour;
|
||||
// if the previous element is selected, it will have its own QuickAddBlock
|
||||
// we use thisId instead of previousEntryId because the block end does not process
|
||||
// and it does not cause the reassignment of the iteration id to the previous entry
|
||||
const showPrependingQuickAdd = isEditMode && cursor !== rundownMetadata.thisId;
|
||||
return (
|
||||
<Fragment key={entryId}>
|
||||
{showPrependingQuickAdd && (
|
||||
<QuickAddBlock
|
||||
previousEventId={rundownMetadata.previousEntryId}
|
||||
parentBlock={parentId}
|
||||
backgroundColor={parentColour}
|
||||
/>
|
||||
)}
|
||||
<BlockEnd key={entryId} id={entryId} colour={parentColour} />
|
||||
{isEditMode && isLast && <QuickAddBlock previousEventId={parentId} parentBlock={null} />}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// we iterate through a stateful copy of order to make the dnd operations smoother
|
||||
// this means that this can be out of sync with order until the useEffect runs
|
||||
// instead of writing all the logic guards, we simply short circuit rendering here
|
||||
const entry = entries[entryId];
|
||||
if (!entry) {
|
||||
if (!entry) return null;
|
||||
rundownMetadata = process(entry);
|
||||
|
||||
// if the entry has a parent, and it is collapsed, render nothing
|
||||
if (
|
||||
entry.type !== SupportedEntry.Block &&
|
||||
rundownMetadata.groupId !== null &&
|
||||
getIsCollapsed(rundownMetadata.groupId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rundownMeta = process(entry);
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === order.length - 1;
|
||||
const isNext = featureData?.nextEventId === entry.id;
|
||||
const hasCursor = entry.id === cursor;
|
||||
|
||||
/**
|
||||
* Outside a block, the value will be undefined
|
||||
* If the colour is empty string ''
|
||||
* ie: we are inside a block, but there is no defined colour
|
||||
* we default to $gray-1050 #303030
|
||||
*/
|
||||
const blockColour = rundownMetadata.groupColour === '' ? '#303030' : rundownMetadata.groupColour;
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
{isEditMode && (hasCursor || isFirst) && (
|
||||
<QuickAddBlock previousEventId={rundownMeta.previousEntryId} parentBlock={null} />
|
||||
<QuickAddBlock
|
||||
previousEventId={rundownMetadata.previousEntryId}
|
||||
parentBlock={isFirst ? null : rundownMetadata.groupId}
|
||||
backgroundColor={isFirst ? undefined : blockColour}
|
||||
/>
|
||||
)}
|
||||
{isOntimeBlock(entry) ? (
|
||||
<BlockBlock data={entry} hasCursor={hasCursor}>
|
||||
{entry.events.length === 0 && (
|
||||
<BlockEmpty
|
||||
handleAddNew={() => insertAtId({ type: SupportedEntry.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;
|
||||
const hasNestedCursor = nestedEntry.id === cursor;
|
||||
|
||||
if (!isOntimeEvent(nestedEntry)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Fragment key={nestedEntry.id}>
|
||||
{isEditMode && (hasNestedCursor || isFirstInGroup) && (
|
||||
<QuickAddBlock
|
||||
parentBlock={entry.id}
|
||||
previousEventId={nestedRundownMeta.previousEntryId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
key={nestedEntry.id}
|
||||
className={style.entryWrapper}
|
||||
data-testid={`entry-${nestedRundownMeta.eventIndex}`}
|
||||
>
|
||||
<div className={style.entryIndex}>{nestedRundownMeta.eventIndex}</div>
|
||||
<div className={style.entry} ref={hasNestedCursor ? cursorRef : undefined}>
|
||||
<RundownEntry
|
||||
key={nestedEntry.id}
|
||||
type={nestedEntry.type}
|
||||
isPast={nestedRundownMeta.isPast}
|
||||
eventIndex={nestedRundownMeta.eventIndex}
|
||||
data={nestedEntry}
|
||||
loaded={nestedRundownMeta.isLoaded}
|
||||
hasCursor={hasNestedCursor}
|
||||
isNext={isNext}
|
||||
previousEntryId={nestedRundownMeta.previousEntryId}
|
||||
previousEventId={nestedRundownMeta.previousEvent?.id}
|
||||
playback={nestedRundownMeta.isLoaded ? featureData.playback : undefined}
|
||||
isRolling={featureData.playback === Playback.Roll}
|
||||
isNextDay={nestedRundownMeta.isNextDay}
|
||||
totalGap={nestedRundownMeta.totalGap}
|
||||
isLinkedToLoaded={nestedRundownMeta.isLinkedToLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isEditMode && (hasNestedCursor || isLastInGroup) && (
|
||||
<QuickAddBlock parentBlock={entry.id} previousEventId={nestedEntry.id} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</BlockBlock>
|
||||
<BlockBlock
|
||||
data={entry}
|
||||
hasCursor={hasCursor}
|
||||
collapsed={getIsCollapsed(entry.id)}
|
||||
onCollapse={handleCollapseGroup}
|
||||
/>
|
||||
) : (
|
||||
<div className={style.entryWrapper} data-testid={`entry-${rundownMeta.eventIndex}`}>
|
||||
{isOntimeEvent(entry) && <div className={style.entryIndex}>{rundownMeta.eventIndex}</div>}
|
||||
<div
|
||||
className={style.entryWrapper}
|
||||
data-testid={`entry-${rundownMetadata.eventIndex}`}
|
||||
style={blockColour ? { '--user-bg': blockColour } : {}}
|
||||
>
|
||||
{isOntimeEvent(entry) && <div className={style.entryIndex}>{rundownMetadata.eventIndex}</div>}
|
||||
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
|
||||
<RundownEntry
|
||||
type={entry.type}
|
||||
isPast={rundownMeta.isPast}
|
||||
eventIndex={rundownMeta.eventIndex}
|
||||
isPast={rundownMetadata.isPast}
|
||||
eventIndex={rundownMetadata.eventIndex}
|
||||
data={entry}
|
||||
loaded={rundownMeta.isLoaded}
|
||||
loaded={rundownMetadata.isLoaded}
|
||||
hasCursor={hasCursor}
|
||||
isNext={isNext}
|
||||
previousEntryId={rundownMeta.previousEntryId}
|
||||
previousEventId={rundownMeta.previousEvent?.id}
|
||||
playback={rundownMeta.isLoaded ? featureData.playback : undefined}
|
||||
previousEntryId={rundownMetadata.previousEntryId}
|
||||
previousEventId={rundownMetadata.previousEvent?.id}
|
||||
playback={rundownMetadata.isLoaded ? featureData.playback : undefined}
|
||||
isRolling={featureData.playback === Playback.Roll}
|
||||
isNextDay={rundownMeta.isNextDay}
|
||||
totalGap={rundownMeta.totalGap}
|
||||
isLinkedToLoaded={rundownMeta.isLinkedToLoaded}
|
||||
isNextDay={rundownMetadata.isNextDay}
|
||||
totalGap={rundownMetadata.totalGap}
|
||||
isLinkedToLoaded={rundownMetadata.isLinkedToLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isEditMode && (hasCursor || isLast) && (
|
||||
<QuickAddBlock previousEventId={entry.id} parentBlock={null} />
|
||||
<QuickAddBlock
|
||||
previousEventId={entry.id}
|
||||
parentBlock={rundownMetadata.groupId}
|
||||
backgroundColor={blockColour}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
@@ -179,6 +179,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
isPast={isPast}
|
||||
isNext={isNext}
|
||||
skip={data.skip}
|
||||
parent={data.parent}
|
||||
loaded={loaded}
|
||||
hasCursor={hasCursor}
|
||||
playback={playback}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OntimeBlock, OntimeEvent, SupportedEntry } from 'ontime-types';
|
||||
import { OntimeBlock, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { makeRundownMetadata } from '../rundown.utils';
|
||||
import { makeRundownMetadata, makeSortableList } from '../rundown.utils';
|
||||
|
||||
describe('makeRundownMetadata()', () => {
|
||||
it('processes nested rundown data', () => {
|
||||
@@ -21,7 +21,8 @@ describe('makeRundownMetadata()', () => {
|
||||
block: {
|
||||
id: 'block',
|
||||
type: SupportedEntry.Block,
|
||||
events: ['11, 12, 13'],
|
||||
events: ['11', '12', '13'],
|
||||
colour: 'red',
|
||||
} as OntimeBlock,
|
||||
'11': {
|
||||
id: '11',
|
||||
@@ -73,7 +74,22 @@ describe('makeRundownMetadata()', () => {
|
||||
} as OntimeEvent,
|
||||
};
|
||||
|
||||
const process = makeRundownMetadata(selectedEventId);
|
||||
const { metadata, process } = makeRundownMetadata(selectedEventId);
|
||||
|
||||
expect(metadata).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: null,
|
||||
previousEntryId: null,
|
||||
thisId: null,
|
||||
eventIndex: 0,
|
||||
isPast: true,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: null,
|
||||
groupColour: undefined,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['1'])).toStrictEqual({
|
||||
previousEvent: null,
|
||||
@@ -82,11 +98,12 @@ describe('makeRundownMetadata()', () => {
|
||||
thisId: demoEvents['1'].id,
|
||||
eventIndex: 1, // UI indexes are 1 based
|
||||
isPast: true,
|
||||
isNext: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: null,
|
||||
groupColour: undefined,
|
||||
});
|
||||
|
||||
expect(process(demoEvents['block'])).toMatchObject({
|
||||
@@ -96,11 +113,12 @@ describe('makeRundownMetadata()', () => {
|
||||
thisId: demoEvents['block'].id,
|
||||
eventIndex: 1,
|
||||
isPast: true,
|
||||
isNext: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'block',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
expect(process(demoEvents['11'])).toMatchObject({
|
||||
@@ -110,11 +128,12 @@ describe('makeRundownMetadata()', () => {
|
||||
thisId: demoEvents['11'].id,
|
||||
eventIndex: 2,
|
||||
isPast: true,
|
||||
isNext: false,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: 'block',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
expect(process(demoEvents['12'])).toMatchObject({
|
||||
@@ -124,11 +143,12 @@ describe('makeRundownMetadata()', () => {
|
||||
thisId: demoEvents['12'].id,
|
||||
eventIndex: 3,
|
||||
isPast: false,
|
||||
isNext: false,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: true,
|
||||
groupId: 'block',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
expect(process(demoEvents['13'])).toMatchObject({
|
||||
@@ -138,11 +158,12 @@ describe('makeRundownMetadata()', () => {
|
||||
thisId: demoEvents['13'].id,
|
||||
eventIndex: 4,
|
||||
isPast: false,
|
||||
isNext: false,
|
||||
isNextDay: false,
|
||||
totalGap: 10,
|
||||
isLinkedToLoaded: true,
|
||||
isLoaded: false,
|
||||
groupId: 'block',
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
expect(process(demoEvents['2'])).toMatchObject({
|
||||
@@ -152,11 +173,142 @@ describe('makeRundownMetadata()', () => {
|
||||
thisId: demoEvents['2'].id,
|
||||
eventIndex: 5,
|
||||
isPast: false,
|
||||
isNext: false,
|
||||
isNextDay: false,
|
||||
totalGap: 17,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: null,
|
||||
groupColour: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates previousEntries in blocks', () => {
|
||||
const rundownStartsWithBlock = {
|
||||
block: {
|
||||
id: 'block',
|
||||
type: SupportedEntry.Block,
|
||||
colour: 'red',
|
||||
events: ['1', '2'],
|
||||
} as OntimeBlock,
|
||||
'1': {
|
||||
id: '1',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'block',
|
||||
timeStart: 1,
|
||||
timeEnd: 2,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
'2': {
|
||||
id: '2',
|
||||
type: SupportedEntry.Event,
|
||||
parent: 'block',
|
||||
timeStart: 2,
|
||||
timeEnd: 3,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent,
|
||||
};
|
||||
const { process } = makeRundownMetadata(null);
|
||||
|
||||
expect(process(rundownStartsWithBlock.block)).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: null,
|
||||
previousEntryId: null,
|
||||
thisId: rundownStartsWithBlock.block.id,
|
||||
eventIndex: 0,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithBlock.block.id,
|
||||
groupColour: 'red',
|
||||
});
|
||||
|
||||
expect(process(rundownStartsWithBlock['1'])).toStrictEqual({
|
||||
previousEvent: null,
|
||||
latestEvent: rundownStartsWithBlock['1'],
|
||||
previousEntryId: rundownStartsWithBlock.block.id,
|
||||
thisId: rundownStartsWithBlock['1'].id,
|
||||
eventIndex: 1,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithBlock.block.id,
|
||||
groupColour: 'red',
|
||||
});
|
||||
expect(process(rundownStartsWithBlock['2'])).toStrictEqual({
|
||||
previousEvent: rundownStartsWithBlock['1'],
|
||||
latestEvent: rundownStartsWithBlock['2'],
|
||||
previousEntryId: rundownStartsWithBlock['1'].id,
|
||||
thisId: rundownStartsWithBlock['2'].id,
|
||||
eventIndex: 2,
|
||||
isPast: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: rundownStartsWithBlock.block.id,
|
||||
groupColour: 'red',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeSortableList()', () => {
|
||||
it('generates a list with block ends', () => {
|
||||
const flatOrder = ['block-1', '11', '2', 'block-3', '31', 'block-4'];
|
||||
const entries: RundownEntries = {
|
||||
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: ['11'] } as OntimeBlock,
|
||||
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
|
||||
'2': { type: SupportedEntry.Event, id: '2', parent: null } as OntimeEvent,
|
||||
'block-3': { type: SupportedEntry.Block, id: 'block-3', events: ['31'] } as OntimeBlock,
|
||||
'31': { type: SupportedEntry.Event, id: '31', parent: 'block-3' } as OntimeEvent,
|
||||
'block-4': { type: SupportedEntry.Block, id: 'block-4', events: [] as string[] } as OntimeBlock,
|
||||
};
|
||||
|
||||
const sortableList = makeSortableList(flatOrder, entries);
|
||||
expect(sortableList).toEqual([
|
||||
'block-1',
|
||||
'11',
|
||||
'end-block-1',
|
||||
'2',
|
||||
'block-3',
|
||||
'31',
|
||||
'end-block-3',
|
||||
'block-4',
|
||||
'end-block-4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes dangling blocks', () => {
|
||||
const flatOrder = ['block', '11', '12'];
|
||||
const entries: RundownEntries = {
|
||||
block: { type: SupportedEntry.Block, id: 'block-1', events: ['11', '12'] } as OntimeBlock,
|
||||
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
|
||||
'12': { type: SupportedEntry.Event, id: '12', parent: 'block-1' } as OntimeEvent,
|
||||
};
|
||||
|
||||
const sortableList = makeSortableList(flatOrder, entries);
|
||||
expect(sortableList).toStrictEqual(['block-1', '11', '12', 'end-block-1']);
|
||||
});
|
||||
|
||||
it('handles a list with a with just blocks', () => {
|
||||
const flatOrder = ['block-1', 'block-2'];
|
||||
const entries: RundownEntries = {
|
||||
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: [] as string[] } as OntimeBlock,
|
||||
'block-2': { type: SupportedEntry.Block, id: 'block-2', events: [] as string[] } as OntimeBlock,
|
||||
};
|
||||
|
||||
const sortableList = makeSortableList(flatOrder, entries);
|
||||
expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@use '../../theme/ontimeColours' as *;
|
||||
@use '../../theme/ontimeStyles' as *;
|
||||
|
||||
$block-width: 33rem;
|
||||
$block-width: 32rem;
|
||||
|
||||
$block-gap: 0.25rem;
|
||||
$block-element-spacing: 0.25rem;
|
||||
@@ -20,7 +20,6 @@ $block-cursor-color: $orange-400;
|
||||
box-sizing: content-box;
|
||||
border: 1px solid $white-7;
|
||||
border-radius: $block-border-radius;
|
||||
margin-block: 0.25rem;
|
||||
position: relative;
|
||||
color: $block-text-color;
|
||||
|
||||
@@ -33,9 +32,11 @@ $block-cursor-color: $orange-400;
|
||||
opacity: 0.3;
|
||||
cursor: grab;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
|
||||
@@ -4,24 +4,25 @@
|
||||
@include block-styling;
|
||||
overflow: hidden;
|
||||
|
||||
min-width: 34rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr auto;
|
||||
grid-template-areas:
|
||||
'binder header'
|
||||
'content content'
|
||||
'footer footer';
|
||||
grid-template-columns: 2rem 1fr;
|
||||
grid-template-areas: 'binder header';
|
||||
align-items: center;
|
||||
// TODO(style fix): groups have an extra bottom margin which interrupt colour
|
||||
margin-block: 0.25rem;
|
||||
|
||||
&.hasCursor {
|
||||
outline: 1px solid $block-cursor-color;
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
border-radius: $block-border-radius $block-border-radius 0 0;
|
||||
}
|
||||
|
||||
.binder {
|
||||
grid-area: binder;
|
||||
height: 100%;
|
||||
background-color: $gray-1050; // to override inline
|
||||
background-color: var(--block-color, $gray-1050);
|
||||
color: $section-white;
|
||||
font-size: 1rem;
|
||||
display: grid;
|
||||
@@ -51,28 +52,23 @@
|
||||
}
|
||||
|
||||
.metaEntry {
|
||||
font-size: calc(1rem - 3px);
|
||||
width: 4.5em;
|
||||
|
||||
:first-child {
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
background-color: color-mix(in srgb, var(--user-bg, $gray-1050) 10%, transparent 90%);
|
||||
grid-area: content;
|
||||
padding-right: 2px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.footer {
|
||||
grid-area: footer;
|
||||
background-color: var(--user-bg, $gray-1050) ;
|
||||
height: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.drag {
|
||||
@include drag-style;
|
||||
|
||||
&.isDragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&.notAllowed {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { PropsWithChildren, useRef } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { IoChevronDown, IoChevronUp, IoReorderTwo } from 'react-icons/io5';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import { OntimeBlock } from 'ontime-types';
|
||||
import { EntryId, OntimeBlock } from 'ontime-types';
|
||||
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||
import { canDrop } from '../rundown.utils';
|
||||
|
||||
import style from './BlockBlock.module.scss';
|
||||
|
||||
interface BlockBlockProps {
|
||||
data: OntimeBlock;
|
||||
hasCursor: boolean;
|
||||
collapsed: boolean;
|
||||
onCollapse: (collapsed: boolean, groupId: EntryId) => void;
|
||||
}
|
||||
|
||||
export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
|
||||
const { data, hasCursor, children } = props;
|
||||
const [collapsed, setCollapsed] = useSessionStorage<boolean>({ key: `block-${data.id}`, defaultValue: false });
|
||||
export default function BlockBlock(props: BlockBlockProps) {
|
||||
const { data, hasCursor, collapsed, onCollapse } = props;
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
const {
|
||||
@@ -27,21 +28,30 @@ export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
isOver,
|
||||
over,
|
||||
} = useSortable({
|
||||
id: data.id,
|
||||
data: {
|
||||
type: 'block',
|
||||
},
|
||||
animateLayoutChanges: () => false,
|
||||
});
|
||||
|
||||
const binderColours = data.colour && getAccessibleColour(data.colour);
|
||||
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
|
||||
|
||||
const dragStyle = {
|
||||
zIndex: isDragging ? 2 : 'inherit',
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'default',
|
||||
};
|
||||
|
||||
const binderColours = data.colour && getAccessibleColour(data.colour);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx([style.block, hasCursor && style.hasCursor])}
|
||||
className={cx([style.block, hasCursor && style.hasCursor, !collapsed && style.expanded])}
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
|
||||
@@ -49,14 +59,19 @@ export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
|
||||
}}
|
||||
>
|
||||
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
|
||||
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
||||
<span
|
||||
className={cx([style.drag, isDragging && style.isDragging, isDragging && !isValidDrop && style.notAllowed])}
|
||||
ref={handleRef}
|
||||
{...dragAttributes}
|
||||
{...dragListeners}
|
||||
>
|
||||
<IoReorderTwo />
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.header}>
|
||||
<div className={style.titleRow}>
|
||||
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
|
||||
<button onClick={() => setCollapsed((prev) => !prev)}>
|
||||
<button onClick={() => onCollapse(!collapsed, data.id)}>
|
||||
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -79,12 +94,6 @@ export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className={style.group} style={binderColours ? { '--user-bg': binderColours.backgroundColor } : {}}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
<div className={style.footer} style={binderColours ? { '--user-bg': binderColours.backgroundColor } : {}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
@use '../blockMixins' as *;
|
||||
|
||||
.blockEnd {
|
||||
cursor: default;
|
||||
height: 0.5rem;
|
||||
background-color: var(--user-bg, $gray-1050);
|
||||
|
||||
border-radius: 0 0 $block-border-radius $block-border-radius;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import style from './BlockEnd.module.scss';
|
||||
|
||||
interface BlockEndProps {
|
||||
id: string;
|
||||
colour?: string;
|
||||
}
|
||||
|
||||
export default function BlockEnd(props: BlockEndProps) {
|
||||
const { id, colour } = props;
|
||||
const {
|
||||
attributes: dragAttributes,
|
||||
listeners: dragListeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id,
|
||||
animateLayoutChanges: () => false,
|
||||
disabled: true, // we do not want to drag end blocks
|
||||
});
|
||||
|
||||
const dragStyle = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={style.blockEnd}
|
||||
ref={setNodeRef}
|
||||
{...dragAttributes}
|
||||
{...dragListeners}
|
||||
style={{
|
||||
...dragStyle,
|
||||
...(colour ? { '--user-bg': colour } : {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
.delay {
|
||||
@include block-styling;
|
||||
|
||||
margin-block: 0.25rem;
|
||||
background-color: $block-bg2;
|
||||
padding-right: 0.5rem;
|
||||
|
||||
|
||||
@@ -25,14 +25,19 @@ export default function DelayBlock(props: DelayBlockProps) {
|
||||
attributes: dragAttributes,
|
||||
listeners: dragListeners,
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id: data.id,
|
||||
data: {
|
||||
type: 'delay',
|
||||
},
|
||||
animateLayoutChanges: () => false,
|
||||
});
|
||||
|
||||
const dragStyle = {
|
||||
zIndex: isDragging ? 2 : 'inherit',
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ $skip-opacity: 0.2;
|
||||
.eventBlock {
|
||||
@include block-styling;
|
||||
background-color: $block-bg;
|
||||
margin-block: 0.25rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'react-icons/io5';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EndAction, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { EndAction, EntryId, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
@@ -45,6 +45,7 @@ interface EventBlockProps {
|
||||
isPast: boolean;
|
||||
isNext: boolean;
|
||||
skip: boolean;
|
||||
parent: EntryId | null;
|
||||
loaded: boolean;
|
||||
hasCursor: boolean;
|
||||
playback?: Playback;
|
||||
@@ -86,6 +87,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
isPast,
|
||||
isNext,
|
||||
skip = false,
|
||||
parent,
|
||||
loaded,
|
||||
hasCursor,
|
||||
playback,
|
||||
@@ -193,6 +195,10 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
transition,
|
||||
} = useSortable({
|
||||
id: eventId,
|
||||
data: {
|
||||
type: 'event',
|
||||
parent,
|
||||
},
|
||||
animateLayoutChanges: () => false,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
margin: 0.25rem 0;
|
||||
padding-block: 0.5rem;
|
||||
font-size: calc(1rem - 3px);
|
||||
margin-left: calc(2em + 0.5rem);
|
||||
padding-left: calc(2em + 0.5rem);
|
||||
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
|
||||
}
|
||||
|
||||
.quickBtn {
|
||||
|
||||
@@ -10,12 +10,13 @@ import style from './QuickAddBlock.module.scss';
|
||||
interface QuickAddBlockProps {
|
||||
previousEventId: MaybeString;
|
||||
parentBlock: MaybeString;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export default memo(QuickAddBlock);
|
||||
|
||||
function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
const { previousEventId, parentBlock } = props;
|
||||
const { previousEventId, parentBlock, backgroundColor } = props;
|
||||
const { addEntry } = useEntryActions();
|
||||
|
||||
const doLinkPrevious = useRef<HTMLInputElement | null>(null);
|
||||
@@ -25,7 +26,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
addEntry(
|
||||
{
|
||||
type: SupportedEntry.Event,
|
||||
parent: parentBlock ?? null,
|
||||
parent: parentBlock,
|
||||
},
|
||||
{
|
||||
after: previousEventId,
|
||||
@@ -38,8 +39,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
|
||||
const addDelay = () => {
|
||||
addEntry(
|
||||
// TODO(v4): add delays to blocks
|
||||
{ type: SupportedEntry.Delay },
|
||||
{ type: SupportedEntry.Delay, parent: parentBlock },
|
||||
{
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
@@ -60,8 +60,15 @@ function QuickAddBlock(props: QuickAddBlockProps) {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* If the colour is empty string ''
|
||||
* ie: we are inside a block, but there is no defined colour
|
||||
* we default to $gray-1050 #303030
|
||||
*/
|
||||
const blockColour = backgroundColor === '' ? '#303030' : backgroundColor;
|
||||
|
||||
return (
|
||||
<div className={style.quickAdd}>
|
||||
<div className={style.quickAdd} style={blockColour ? { '--user-bg': blockColour } : {}}>
|
||||
<Button
|
||||
onClick={addEvent}
|
||||
size='xs'
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { isOntimeEvent, isPlayableEvent, MaybeString, OntimeEntry, PlayableEvent } from 'ontime-types';
|
||||
import {
|
||||
EntryId,
|
||||
isOntimeBlock,
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
MaybeString,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
PlayableEvent,
|
||||
RundownEntries,
|
||||
SupportedEntry,
|
||||
} from 'ontime-types';
|
||||
import { checkIsNextDay, isNewLatest } from 'ontime-utils';
|
||||
|
||||
type RundownMetadata = {
|
||||
@@ -8,11 +20,12 @@ type RundownMetadata = {
|
||||
thisId: MaybeString;
|
||||
eventIndex: number;
|
||||
isPast: boolean;
|
||||
isNext: boolean;
|
||||
isNextDay: boolean;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event
|
||||
isLoaded: boolean;
|
||||
groupId: MaybeString;
|
||||
groupColour: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -26,11 +39,12 @@ export function makeRundownMetadata(selectedEventId: MaybeString) {
|
||||
thisId: null,
|
||||
eventIndex: 0,
|
||||
isPast: Boolean(selectedEventId), // all events before the current selected are in the past
|
||||
isNext: false,
|
||||
isNextDay: false,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
groupId: null,
|
||||
groupColour: undefined,
|
||||
};
|
||||
|
||||
function process(entry: OntimeEntry): Readonly<RundownMetadata> {
|
||||
@@ -39,12 +53,11 @@ export function makeRundownMetadata(selectedEventId: MaybeString) {
|
||||
return rundownMeta;
|
||||
}
|
||||
|
||||
return process;
|
||||
return { metadata: rundownMeta, process };
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives a rundown entry and processes its place in the rundown
|
||||
*
|
||||
*/
|
||||
function processEntry(
|
||||
rundownMetadata: RundownMetadata,
|
||||
@@ -52,10 +65,12 @@ function processEntry(
|
||||
entry: Readonly<OntimeEntry>,
|
||||
): Readonly<RundownMetadata> {
|
||||
const processedData = { ...rundownMetadata };
|
||||
// initialise data to be overridden below
|
||||
processedData.isNextDay = false;
|
||||
processedData.isLoaded = false;
|
||||
processedData.previousEntryId = processedData.thisId;
|
||||
processedData.thisId = entry.id;
|
||||
|
||||
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
|
||||
processedData.thisId = entry.id; // we reassign thisId
|
||||
processedData.previousEvent = processedData.latestEvent;
|
||||
|
||||
if (entry.id === selectedEventId) {
|
||||
@@ -63,29 +78,103 @@ function processEntry(
|
||||
processedData.isPast = false;
|
||||
}
|
||||
|
||||
if (isOntimeEvent(entry)) {
|
||||
// event indexes are 1 based in UI
|
||||
processedData.eventIndex += 1;
|
||||
|
||||
if (isPlayableEvent(entry)) {
|
||||
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
||||
processedData.totalGap += entry.gap;
|
||||
|
||||
if (!processedData.isPast && !processedData.isLoaded) {
|
||||
/**
|
||||
* isLinkToLoaded is a chain value that we maintain until we
|
||||
* a) find an unlinked event
|
||||
* b) find a countToEnd event
|
||||
*/
|
||||
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
|
||||
if (isOntimeBlock(entry)) {
|
||||
processedData.groupId = entry.id;
|
||||
processedData.groupColour = entry.colour;
|
||||
} else {
|
||||
// for delays and blocks, we insert the group metadata
|
||||
if ((entry as OntimeEvent | OntimeDelay).parent !== processedData.groupId) {
|
||||
// if the parent is not the current group, we need to update the groupId
|
||||
processedData.groupId = (entry as OntimeEvent | OntimeDelay).parent;
|
||||
if ((entry as OntimeEvent | OntimeDelay).parent === null) {
|
||||
// if the entry has no parent, it cannot have a group colour
|
||||
processedData.groupColour = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewLatest(entry, processedData.previousEvent)) {
|
||||
// this event is the forward most event in rundown, for next iteration
|
||||
processedData.latestEvent = entry;
|
||||
if (isOntimeEvent(entry)) {
|
||||
// event indexes are 1 based in UI
|
||||
processedData.eventIndex += 1;
|
||||
|
||||
if (isPlayableEvent(entry)) {
|
||||
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
|
||||
processedData.totalGap += entry.gap;
|
||||
|
||||
if (!processedData.isPast && !processedData.isLoaded) {
|
||||
/**
|
||||
* isLinkToLoaded is a chain value that we maintain until we
|
||||
* a) find an unlinked event
|
||||
* b) find a countToEnd event
|
||||
*/
|
||||
processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
|
||||
}
|
||||
|
||||
if (isNewLatest(entry, processedData.latestEvent)) {
|
||||
// this event is the forward most event in rundown, for next iteration
|
||||
processedData.latestEvent = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a sortable list of entries
|
||||
* ------------------------------------
|
||||
* Due to limitations in dnd-kit we need to flatten the list of entries
|
||||
* This list should also be aware of any elements that are sortable (ie: block ends)
|
||||
*/
|
||||
export function makeSortableList(flatOrder: EntryId[], entries: RundownEntries): EntryId[] {
|
||||
const entryIds: EntryId[] = [];
|
||||
let lastSeenBlock: MaybeString = null;
|
||||
|
||||
for (let i = 0; i < flatOrder.length; i++) {
|
||||
const entry = entries[flatOrder[i]];
|
||||
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
// close any previous blocks
|
||||
if (lastSeenBlock !== null) {
|
||||
entryIds.push(`end-${lastSeenBlock}`);
|
||||
}
|
||||
lastSeenBlock = entry.id;
|
||||
}
|
||||
|
||||
if (isOntimeEvent(entry)) {
|
||||
// Close the previous block if the parent changes
|
||||
if (lastSeenBlock !== null && entry.parent !== lastSeenBlock) {
|
||||
entryIds.push(`end-${lastSeenBlock}`);
|
||||
}
|
||||
lastSeenBlock = entry.parent;
|
||||
}
|
||||
|
||||
entryIds.push(entry.id);
|
||||
}
|
||||
|
||||
// double check that we close any dangling blocks
|
||||
// - if the last element is a block
|
||||
// - if a rundown only has a top level block
|
||||
if (lastSeenBlock !== null) {
|
||||
entryIds.push(`end-${lastSeenBlock}`);
|
||||
}
|
||||
|
||||
return entryIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a drop operation is valid
|
||||
* Currently only used for validating dropping blocks
|
||||
*/
|
||||
export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | null): boolean {
|
||||
if (targetType === 'event' || targetType === 'delay') {
|
||||
return targetParent === null;
|
||||
}
|
||||
// remaining events will be block or end-block
|
||||
// we can swap places with other blocks
|
||||
return targetType == 'block';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user