From e30b4fa8d55db61535e64121ad3c9f770a34aa6f Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Thu, 17 Apr 2025 12:56:47 +0200 Subject: [PATCH] refactor: extract rundown parsing refactor: implement groups in editor --- apps/client/package.json | 4 +- .../client/src/common/hooks/useEntryAction.ts | 8 +- .../src/features/rundown/BlockEmpty.tsx | 20 -- .../src/features/rundown/Rundown.module.scss | 1 + apps/client/src/features/rundown/Rundown.tsx | 276 ++++++++++++------ .../src/features/rundown/RundownEntry.tsx | 1 + .../rundown/__tests__/rundown.utils.test.ts | 172 ++++++++++- .../src/features/rundown/_blockMixins.scss | 5 +- .../block-block/BlockBlock.module.scss | 40 ++- .../rundown/block-block/BlockBlock.tsx | 43 +-- .../rundown/block-block/BlockEnd.module.scss | 10 + .../features/rundown/block-block/BlockEnd.tsx | 42 +++ .../delay-block/DelayBlock.module.scss | 1 + .../rundown/delay-block/DelayBlock.tsx | 5 + .../event-block/EventBlock.module.scss | 1 + .../rundown/event-block/EventBlock.tsx | 8 +- .../quick-add-block/QuickAddBlock.module.scss | 5 +- .../rundown/quick-add-block/QuickAddBlock.tsx | 17 +- .../src/features/rundown/rundown.utils.ts | 139 +++++++-- .../src/api-data/excel/excel.service.ts | 4 +- .../rundown/__tests__/rundown.parser.test.ts | 205 +++++++++++++ .../rundown/__tests__/rundown.utils.test.ts | 80 +++++ .../src/api-data/rundown/rundown.parser.ts | 172 +++++++++++ .../src/api-data/rundown/rundown.utils.ts | 87 ++++++ apps/server/src/models/eventsDefinition.ts | 1 + .../project-service/ProjectService.ts | 2 +- .../rundown-service/RundownService.ts | 2 +- .../services/rundown-service/rundownCache.ts | 2 +- .../services/sheet-service/SheetService.ts | 2 +- .../server/src/utils/__tests__/parser.test.ts | 81 +---- .../utils/__tests__/parserFunctions.test.ts | 206 +------------ apps/server/src/utils/parser.ts | 88 +----- apps/server/src/utils/parserFunctions.ts | 177 +---------- .../BackendResponse.type.ts | 2 +- .../src/definitions/core/OntimeEvent.type.ts | 1 + pnpm-lock.yaml | 32 +- 36 files changed, 1170 insertions(+), 772 deletions(-) delete mode 100644 apps/client/src/features/rundown/BlockEmpty.tsx create mode 100644 apps/client/src/features/rundown/block-block/BlockEnd.module.scss create mode 100644 apps/client/src/features/rundown/block-block/BlockEnd.tsx create mode 100644 apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts create mode 100644 apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts create mode 100644 apps/server/src/api-data/rundown/rundown.parser.ts create mode 100644 apps/server/src/api-data/rundown/rundown.utils.ts diff --git a/apps/client/package.json b/apps/client/package.json index 8d202a718..6632c4fe1 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -5,8 +5,8 @@ "type": "module", "dependencies": { "@chakra-ui/react": "^2.7.0", - "@dnd-kit/core": "^6.1.0", - "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emotion/is-prop-valid": "^1.3.1", "@emotion/react": "^11.10.6", diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 8ac0c669d..30423cf9e 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -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, 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, 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); } diff --git a/apps/client/src/features/rundown/BlockEmpty.tsx b/apps/client/src/features/rundown/BlockEmpty.tsx deleted file mode 100644 index f828968e8..000000000 --- a/apps/client/src/features/rundown/BlockEmpty.tsx +++ /dev/null @@ -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 ( -
- -
- ); -} diff --git a/apps/client/src/features/rundown/Rundown.module.scss b/apps/client/src/features/rundown/Rundown.module.scss index a5da6dd8e..97da8a487 100644 --- a/apps/client/src/features/rundown/Rundown.module.scss +++ b/apps/client/src/features/rundown/Rundown.module.scss @@ -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 { diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 7951f9111..7e2ef3be3 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -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(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(() => makeSortableList(flatOrder, entries)); + const [collapsedGroups, setCollapsedGroups] = useSessionStorage({ + // 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 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 (
- - + +
- {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 ; + } 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 ( + + {showPrependingQuickAdd && ( + + )} + + {isEditMode && isLast && } + + ); + } + } + + // 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 ( {isEditMode && (hasCursor || isFirst) && ( - + )} {isOntimeBlock(entry) ? ( - - {entry.events.length === 0 && ( - 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 ( - - {isEditMode && (hasNestedCursor || isFirstInGroup) && ( - - )} - -
-
{nestedRundownMeta.eventIndex}
-
- -
-
- {isEditMode && (hasNestedCursor || isLastInGroup) && ( - - )} -
- ); - })} -
+ ) : ( -
- {isOntimeEvent(entry) &&
{rundownMeta.eventIndex}
} +
+ {isOntimeEvent(entry) &&
{rundownMetadata.eventIndex}
}
)} {isEditMode && (hasCursor || isLast) && ( - + )} ); diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 7e22b4720..bdee1045e 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -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} diff --git a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts index c02512610..ebc612d09 100644 --- a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts +++ b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts @@ -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']); + }); +}); diff --git a/apps/client/src/features/rundown/_blockMixins.scss b/apps/client/src/features/rundown/_blockMixins.scss index 8f769edb3..f981f3b1a 100644 --- a/apps/client/src/features/rundown/_blockMixins.scss +++ b/apps/client/src/features/rundown/_blockMixins.scss @@ -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; diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.module.scss b/apps/client/src/features/rundown/block-block/BlockBlock.module.scss index 52830d6ea..df2a63b13 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.module.scss +++ b/apps/client/src/features/rundown/block-block/BlockBlock.module.scss @@ -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; + } } diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.tsx b/apps/client/src/features/rundown/block-block/BlockBlock.tsx index fb42bc84a..d2c9ef88b 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.tsx +++ b/apps/client/src/features/rundown/block-block/BlockBlock.tsx @@ -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) { - const { data, hasCursor, children } = props; - const [collapsed, setCollapsed] = useSessionStorage({ key: `block-${data.id}`, defaultValue: false }); +export default function BlockBlock(props: BlockBlockProps) { + const { data, hasCursor, collapsed, onCollapse } = props; const handleRef = useRef(null); const { @@ -27,21 +28,30 @@ export default function BlockBlock(props: PropsWithChildren) { 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 (
) { }} >
- +
-
@@ -79,12 +94,6 @@ export default function BlockBlock(props: PropsWithChildren) {
- {!collapsed && ( -
- {children} -
- )} -
); } diff --git a/apps/client/src/features/rundown/block-block/BlockEnd.module.scss b/apps/client/src/features/rundown/block-block/BlockEnd.module.scss new file mode 100644 index 000000000..93f06d606 --- /dev/null +++ b/apps/client/src/features/rundown/block-block/BlockEnd.module.scss @@ -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; +} diff --git a/apps/client/src/features/rundown/block-block/BlockEnd.tsx b/apps/client/src/features/rundown/block-block/BlockEnd.tsx new file mode 100644 index 000000000..a357da421 --- /dev/null +++ b/apps/client/src/features/rundown/block-block/BlockEnd.tsx @@ -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 ( +
+ ); +} diff --git a/apps/client/src/features/rundown/delay-block/DelayBlock.module.scss b/apps/client/src/features/rundown/delay-block/DelayBlock.module.scss index 0b93a38e6..bebb4d1da 100644 --- a/apps/client/src/features/rundown/delay-block/DelayBlock.module.scss +++ b/apps/client/src/features/rundown/delay-block/DelayBlock.module.scss @@ -3,6 +3,7 @@ .delay { @include block-styling; + margin-block: 0.25rem; background-color: $block-bg2; padding-right: 0.5rem; diff --git a/apps/client/src/features/rundown/delay-block/DelayBlock.tsx b/apps/client/src/features/rundown/delay-block/DelayBlock.tsx index 5b15fbcae..53a9ceeaf 100644 --- a/apps/client/src/features/rundown/delay-block/DelayBlock.tsx +++ b/apps/client/src/features/rundown/delay-block/DelayBlock.tsx @@ -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, }; diff --git a/apps/client/src/features/rundown/event-block/EventBlock.module.scss b/apps/client/src/features/rundown/event-block/EventBlock.module.scss index 33cec4252..5da13edeb 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.module.scss +++ b/apps/client/src/features/rundown/event-block/EventBlock.module.scss @@ -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: diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 2ddd307c2..73f6fd9a8 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -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, }); diff --git a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.module.scss b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.module.scss index f62d5555c..d73299360 100644 --- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.module.scss +++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.module.scss @@ -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 { diff --git a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx index a06b57253..a1c88c6c9 100644 --- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx +++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx @@ -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(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 ( -
+