diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 1cb17bfb9..7cedda359 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -4,9 +4,9 @@ import { ChakraProvider } from '@chakra-ui/react'; import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { ContextMenu } from './common/components/context-menu/ContextMenu'; import ErrorBoundary from './common/components/error-boundary/ErrorBoundary'; import { AppContextProvider } from './common/context/AppContext'; -import { ContextMenuProvider } from './common/context/ContextMenuContext'; import useElectronEvent from './common/hooks/useElectronEvent'; import { ontimeQueryClient } from './common/queryClient'; import { socketClientName } from './common/stores/connectionName'; @@ -52,18 +52,18 @@ function App() { - - -
- - + +
+ + + - - - -
-
- + +
+
+ +
+
diff --git a/apps/client/src/common/api/eventsApi.ts b/apps/client/src/common/api/eventsApi.ts index c6ad73f67..bb574e340 100644 --- a/apps/client/src/common/api/eventsApi.ts +++ b/apps/client/src/common/api/eventsApi.ts @@ -50,6 +50,19 @@ export async function requestApplyDelay(eventId: string) { return axios.patch(`${rundownURL}/applydelay/${eventId}`); } +export type SwapEntry = { + from: string; + to: string; +}; + +/** + * @description HTTP request to swap two events + * @return {Promise} + */ +export async function requestEventSwap(data: SwapEntry) { + return axios.patch(`${rundownURL}/swap`, data); +} + /** * @description HTTP request to delete given event * @return {Promise} diff --git a/apps/client/src/common/context/ContextMenuContext.module.scss b/apps/client/src/common/components/context-menu/ContextMenu.module.scss similarity index 100% rename from apps/client/src/common/context/ContextMenuContext.module.scss rename to apps/client/src/common/components/context-menu/ContextMenu.module.scss diff --git a/apps/client/src/common/components/context-menu/ContextMenu.tsx b/apps/client/src/common/components/context-menu/ContextMenu.tsx new file mode 100644 index 000000000..68d1932df --- /dev/null +++ b/apps/client/src/common/components/context-menu/ContextMenu.tsx @@ -0,0 +1,84 @@ +// logic (with some modifications) culled from: +// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx + +import { Fragment, ReactElement } from 'react'; +import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react'; +import { IconType } from '@react-icons/all-files'; +import { create } from 'zustand'; + +import style from './ContextMenu.module.scss'; + +type ContextMenuCoords = { + x: number; + y: number; +}; + +export type Option = { + label: string; + icon: IconType; + onClick: () => void; + withDivider?: boolean; + isDisabled?: boolean; +}; + +type ContextMenuStore = { + coords: ContextMenuCoords; + options: Option[]; + isOpen: boolean; + setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void; + setIsOpen: (newIsOpen: boolean) => void; +}; + +export const useContextMenuStore = create((set) => ({ + coords: { x: 0, y: 0 }, + options: [], + isOpen: false, + setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })), + setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })), +})); + +interface ContextMenuProps { + // ReactElement type required due to early `return` (line 51) returning {children} + children: ReactElement; +} + +export const ContextMenu = ({ children }: ContextMenuProps) => { + const { coords, options, isOpen, setIsOpen } = useContextMenuStore(); + + const onClose = () => { + return setIsOpen(false); + }; + + if (!isOpen) { + return children; + } + + return ( + <> + {children} +
+ + + + {options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => ( + + {withDivider && } + } onClick={onClick} isDisabled={isDisabled}> + {label} + + + ))} + + + + ); +}; diff --git a/apps/client/src/common/context/ContextMenuContext.tsx b/apps/client/src/common/context/ContextMenuContext.tsx deleted file mode 100644 index 2504c332e..000000000 --- a/apps/client/src/common/context/ContextMenuContext.tsx +++ /dev/null @@ -1,76 +0,0 @@ -// logic (with some modifications) culled from: -// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx - -import { createContext, ReactNode, useState } from 'react'; -import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react'; -import { IconType } from '@react-icons/all-files'; - -import style from './ContextMenuContext.module.scss'; - -type ContextMenuCoords = { - x: number; - y: number; -}; - -type ContextMenuContextType = { - createContextMenu: (options: Option[], menuCoordinates: ContextMenuCoords) => void; -}; - -export const ContextMenuContext = createContext(null); - -export type Option = { - label: string; - icon: IconType; - onClick: () => void; -}; - -interface ContextMenuProviderProps { - children: ReactNode; -} - -export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { - const [isOpen, setIsOpen] = useState(false); - - const [coords, setCoords] = useState({ x: 0, y: 0 }); - const [options, setOptions] = useState([]); - - const onClose = () => { - return setIsOpen(false); - }; - - const createContextMenu = (options: Option[], menuCoords: ContextMenuCoords) => { - setCoords(menuCoords); - setOptions(options); - setIsOpen(true); - }; - - return ( - - {children} - {isOpen && ( - <> -
- - - - {options.map(({ label, icon: Icon, onClick }, i) => ( - } onClick={onClick}> - {label} - - ))} - - - - )} - - ); -}; diff --git a/apps/client/src/common/hooks/useContextMenu.tsx b/apps/client/src/common/hooks/useContextMenu.tsx index d21f574f6..d98a4fba4 100644 --- a/apps/client/src/common/hooks/useContextMenu.tsx +++ b/apps/client/src/common/hooks/useContextMenu.tsx @@ -1,22 +1,16 @@ -import { MouseEvent, useContext } from 'react'; +import { MouseEvent } from 'react'; -import { ContextMenuContext, Option } from '../context/ContextMenuContext'; +import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu'; export const useContextMenu = (options: Option[]) => { - const contextMenuContext = useContext(ContextMenuContext); - - if (contextMenuContext === null) { - throw new Error('useContextMenu should be wrapped by ContextMenuProvider'); - } - - const { createContextMenu } = contextMenuContext; + const { setContextMenu } = useContextMenuStore(); const localCreateContextMenu = (contextMenuEvent: MouseEvent) => { // prevent browser default context menu from showing up contextMenuEvent.preventDefault(); const { pageX, pageY } = contextMenuEvent; - return createContextMenu(options, { x: pageX, y: pageY }); + return setContextMenu({ x: pageX, y: pageY }, options); }; return [localCreateContextMenu]; diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEventAction.ts index 4cbc8f2d8..ca489ee39 100644 --- a/apps/client/src/common/hooks/useEventAction.ts +++ b/apps/client/src/common/hooks/useEventAction.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; +import { swapOntimeEvents } from 'ontime-utils'; import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants'; import { logAxiosError } from '../api/apiUtils'; @@ -9,9 +10,11 @@ import { requestApplyDelay, requestDelete, requestDeleteAll, + requestEventSwap, requestPostEvent, requestPutEvent, requestReorderEvent, + SwapEntry, } from '../api/eventsApi'; import { useEditorSettings } from '../stores/editorSettings'; @@ -28,9 +31,10 @@ export const useEventAction = () => { * Calls mutation to add new event * @private */ - const _addEventMutation = useMutation(requestPostEvent, { + const _addEventMutation = useMutation({ // Mutation finished, failed or successful // Fetch anyway, just to be sure + mutationFn: requestPostEvent, onSettled: () => { queryClient.invalidateQueries(RUNDOWN_TABLE); }, @@ -102,7 +106,8 @@ export const useEventAction = () => { * Calls mutation to update existing event * @private */ - const _updateEventMutation = useMutation(requestPutEvent, { + const _updateEventMutation = useMutation({ + mutationFn: requestPutEvent, // we optimistically update here onMutate: async (newEvent) => { // cancel ongoing queries @@ -117,11 +122,11 @@ export const useEventAction = () => { // Return a context with the previous and new events return { previousEvent, newEvent }; }, - // Mutation fails, rollback undoes optimist update onError: (_error, _newEvent, context) => { queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent); }, + // Mutation finished, failed or successful // Fetch anyway, just to be sure onSettled: async () => { @@ -148,7 +153,8 @@ export const useEventAction = () => { * Calls mutation to delete an event * @private */ - const _deleteEventMutation = useMutation(requestDelete, { + const _deleteEventMutation = useMutation({ + mutationFn: requestDelete, // we optimistically update here onMutate: async (eventId) => { // cancel ongoing queries @@ -196,7 +202,8 @@ export const useEventAction = () => { * Calls mutation to delete all events * @private */ - const _deleteAllEventsMutation = useMutation(requestDeleteAll, { + const _deleteAllEventsMutation = useMutation({ + mutationFn: requestDeleteAll, // we optimistically update here onMutate: async () => { // cancel ongoing queries @@ -239,7 +246,8 @@ export const useEventAction = () => { * Calls mutation to apply a delay * @private */ - const _applyDelayMutation = useMutation(requestApplyDelay, { + const _applyDelayMutation = useMutation({ + mutationFn: requestApplyDelay, // Mutation finished, failed or successful onSettled: () => { queryClient.invalidateQueries(RUNDOWN_TABLE); @@ -265,7 +273,8 @@ export const useEventAction = () => { * Calls mutation to reorder an event * @private */ - const _reorderEventMutation = useMutation(requestReorderEvent, { + const _reorderEventMutation = useMutation({ + mutationFn: requestReorderEvent, // we optimistically update here onMutate: async (data) => { // cancel ongoing queries @@ -316,5 +325,67 @@ export const useEventAction = () => { [_reorderEventMutation], ); - return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent }; + /** + * Calls mutation to swap events + * @private + */ + const _swapEvents = useMutation({ + mutationFn: requestEventSwap, + // we optimistically update here + onMutate: async ({ from, to }) => { + // cancel ongoing queries + await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true }); + + // Snapshot the previous value + const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown; + + const fromEventIndex = rundown.findIndex((event) => event.id === from); + const toEventIndex = rundown.findIndex((event) => event.id === to); + + const previousEvents = swapOntimeEvents(rundown, fromEventIndex, toEventIndex); + + // optimistically update object + queryClient.setQueryData(RUNDOWN_TABLE, previousEvents); + + // Return a context with the previous events + return { previousEvents }; + }, + + // Mutation fails, rollback undoes optimist update + onError: (_error, _eventId, context) => { + queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents); + }, + // Mutation finished, failed or successful + // Fetch anyway, just to be sure + onSettled: () => { + queryClient.invalidateQueries(RUNDOWN_TABLE); + }, + networkMode: 'always', + }); + + /** + * Swaps the schedule of two events + */ + const swapEvents = useCallback( + async ({ from, to }: SwapEntry) => { + // TODO: before calling `/swapEvents`, + // we should determine the events are of type `OntimeEvent` + try { + await _swapEvents.mutateAsync({ from, to }); + } catch (error) { + logAxiosError('Error re-ordering event', error); + } + }, + [_swapEvents], + ); + + return { + addEvent, + updateEvent, + deleteEvent, + deleteAllEvents, + applyDelay, + reorderEvent, + swapEvents, + }; }; diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 095b20998..1904b6ce1 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -12,7 +12,7 @@ import BlockBlock from './block-block/BlockBlock'; import DelayBlock from './delay-block/DelayBlock'; import EventBlock from './event-block/EventBlock'; -export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update'; +export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update' | 'swap'; interface RundownEntryProps { type: SupportedEvent; @@ -44,7 +44,7 @@ export default function RundownEntry(props: RundownEntryProps) { disableEdit, } = props; const { emitError } = useEmitLog(); - const { addEvent, updateEvent, deleteEvent } = useEventAction(); + const { addEvent, updateEvent, deleteEvent, swapEvents } = useEventAction(); const cursor = useAppMode((state) => state.cursor); const setCursor = useAppMode((state) => state.setCursor); @@ -95,6 +95,12 @@ export default function RundownEntry(props: RundownEntryProps) { addEvent({ type: SupportedEvent.Block }, { after: data.id }); break; } + case 'swap': { + const { value } = payload as FieldValue; + swapEvents({ from: value as string, to: data.id }); + + break; + } case 'delete': { if (openId === data.id) { removeOpenEvent(); @@ -149,6 +155,7 @@ export default function RundownEntry(props: RundownEntryProps) { removeOpenEvent, startTimeIsLastEnd, updateEvent, + swapEvents, ], ); diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index a2945ce1f..1fc735429 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -1,17 +1,19 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline'; import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline'; import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo'; +import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types'; import { useContextMenu } from '../../../common/hooks/useContextMenu'; -import { useEventAction } from '../../../common/hooks/useEventAction'; import { useAppMode } from '../../../common/stores/appModeStore'; import copyToClipboard from '../../../common/utils/copyToClipboard'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import type { EventItemActions } from '../RundownEntry'; +import { useEventIdSwapping } from '../useEventIdSwapping'; import EventBlockInner from './EventBlockInner'; @@ -75,22 +77,37 @@ export default function EventBlock(props: EventBlockProps) { actionHandler, disableEdit, } = props; - const { updateEvent } = useEventAction(); + const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); const moveCursorTo = useAppMode((state) => state.setCursor); const handleRef = useRef(null); const [isVisible, setIsVisible] = useState(false); const openId = useAppMode((state) => state.editId); const [onContextMenu] = useContextMenu([ - { label: `Copy ID: ${eventId}}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) }, + { label: `Copy ID: ${eventId}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) }, { label: 'Toggle public', icon: IoPeopleOutline, onClick: () => - updateEvent({ - id: eventId, - isPublic: !isPublic, + actionHandler('update', { + field: 'isPublic', + value: !isPublic, }), }, + { + label: 'Add to swap', + icon: IoAdd, + onClick: () => setSelectedEventId(eventId), + withDivider: true, + }, + { + label: `Swap this event with ${selectedEventId ?? ''}`, + icon: IoSwapVertical, + onClick: () => { + actionHandler('swap', { field: 'id', value: selectedEventId }); + clearSelectedEventId(); + }, + isDisabled: selectedEventId == null || selectedEventId === eventId, + }, ]); const { diff --git a/apps/client/src/features/rundown/useEventIdSwapping.ts b/apps/client/src/features/rundown/useEventIdSwapping.ts new file mode 100644 index 000000000..a93a1c356 --- /dev/null +++ b/apps/client/src/features/rundown/useEventIdSwapping.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +interface EventIdSwappingStore { + selectedEventId: string | null; + setSelectedEventId: (newEventId: string | null) => void; + clearSelectedEventId: () => void; +} + +export const useEventIdSwapping = create((set) => ({ + selectedEventId: null, + setSelectedEventId: (newEventId) => set(() => ({ selectedEventId: newEventId })), + clearSelectedEventId: () => set(() => ({ selectedEventId: null })), +})); diff --git a/apps/server/src/controllers/rundownController.ts b/apps/server/src/controllers/rundownController.ts index 46b3346ab..c2a00231a 100644 --- a/apps/server/src/controllers/rundownController.ts +++ b/apps/server/src/controllers/rundownController.ts @@ -1,4 +1,3 @@ -import { OntimeEvent } from 'ontime-types'; import { failEmptyObjects } from '../utils/routerUtils.js'; import { addEvent, @@ -7,19 +6,21 @@ import { deleteEvent, editEvent, reorderEvent, + swapEvents, } from '../services/rundown-service/RundownService.js'; import { getDelayedRundown } from '../services/rundown-service/delayedRundown.utils.js'; +import { RequestHandler } from 'express'; // Create controller for GET request to '/events' // Returns - -export const rundownGetAll = async (req, res) => { +export const rundownGetAll: RequestHandler = async (_req, res) => { const delayedRundown = getDelayedRundown(); res.json(delayedRundown); }; // Create controller for POST request to '/events/' // Returns - -export const rundownPost = async (req, res) => { +export const rundownPost: RequestHandler = async (req, res) => { if (failEmptyObjects(req.body, res)) { return; } @@ -34,7 +35,7 @@ export const rundownPost = async (req, res) => { // Create controller for PUT request to '/events/' // Returns - -export const rundownPut = async (req, res) => { +export const rundownPut: RequestHandler = async (req, res) => { if (failEmptyObjects(req.body, res)) { return; } @@ -47,7 +48,7 @@ export const rundownPut = async (req, res) => { } }; -export const rundownReorder = async (req, res) => { +export const rundownReorder: RequestHandler = async (req, res) => { if (failEmptyObjects(req.body, res)) { return; } @@ -61,9 +62,23 @@ export const rundownReorder = async (req, res) => { } }; +export const rundownSwap: RequestHandler = async (req, res) => { + if (failEmptyObjects(req.body, res)) { + return; + } + + try { + const { from, to } = req.body; + await swapEvents(from, to); + res.sendStatus(200); + } catch (error) { + res.status(400).send(error); + } +}; + // Create controller for PATCH request to '/events/applydelay/:eventId' // Returns - -export const rundownApplyDelay = async (req, res) => { +export const rundownApplyDelay: RequestHandler = async (req, res) => { try { await applyDelay(req.params.eventId); res.sendStatus(200); @@ -74,7 +89,7 @@ export const rundownApplyDelay = async (req, res) => { // Create controller for DELETE request to '/events/:eventId' // Returns - -export const deleteEventById = async (req, res) => { +export const deleteEventById: RequestHandler = async (req, res) => { try { await deleteEvent(req.params.eventId); res.sendStatus(204); @@ -85,7 +100,7 @@ export const deleteEventById = async (req, res) => { // Create controller for DELETE request to '/events/' // Returns - -export const rundownDelete = async (req, res) => { +export const rundownDelete: RequestHandler = async (req, res) => { try { await deleteAllEvents(); res.sendStatus(204); diff --git a/apps/server/src/controllers/rundownController.validate.ts b/apps/server/src/controllers/rundownController.validate.ts index 05f2f481b..31d3230d4 100644 --- a/apps/server/src/controllers/rundownController.validate.ts +++ b/apps/server/src/controllers/rundownController.validate.ts @@ -29,6 +29,16 @@ export const rundownReorderValidator = [ }, ]; +export const rundownSwapValidator = [ + body('from').isString().exists(), + body('to').isString().exists(), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + export const paramsMustHaveEventId = [ param('eventId').exists(), (req, res, next) => { diff --git a/apps/server/src/routes/rundownRouter.ts b/apps/server/src/routes/rundownRouter.ts index e4957f53f..63de25641 100644 --- a/apps/server/src/routes/rundownRouter.ts +++ b/apps/server/src/routes/rundownRouter.ts @@ -7,12 +7,14 @@ import { rundownPost, rundownPut, rundownReorder, + rundownSwap, } from '../controllers/rundownController.js'; import { paramsMustHaveEventId, rundownPostValidator, rundownPutValidator, rundownReorderValidator, + rundownSwapValidator, } from '../controllers/rundownController.validate.js'; export const router = express.Router(); @@ -29,6 +31,8 @@ router.put('/', rundownPutValidator, rundownPut); // create route between controller and '/events/reorder' endpoint router.patch('/reorder/', rundownReorderValidator, rundownReorder); +router.patch('/swap', rundownSwapValidator, rundownSwap); + // create route between controller and '/events/applydelay/:eventId' endpoint router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index bfaa2f385..95c714320 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -22,6 +22,7 @@ import { cachedDelete, cachedEdit, cachedReorder, + cachedSwap, delayedRundownCacheKey, } from './delayedRundown.utils.js'; import { logger } from '../../classes/Logger.js'; @@ -304,6 +305,22 @@ export function _applyDelay( return { delayIndex, updatedRundown }; } +/** + * swaps two events + * @param {string} from - id of event from + * @param {string} to - id of event to + * @returns {Promise} + */ +export async function swapEvents(from: string, to: string) { + await cachedSwap(from, to); + + // notify timer service of changed events + updateTimer(); + + // advice socket subscribers of change + sendRefetch(); +} + /** * applies delay value for given event * @param eventId diff --git a/apps/server/src/services/rundown-service/delayedRundown.utils.ts b/apps/server/src/services/rundown-service/delayedRundown.utils.ts index 7fc19c12b..e2c5a6914 100644 --- a/apps/server/src/services/rundown-service/delayedRundown.utils.ts +++ b/apps/server/src/services/rundown-service/delayedRundown.utils.ts @@ -1,8 +1,10 @@ import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types'; + import { DataProvider } from '../../classes/data-provider/DataProvider.js'; import { getCached, runtimeCacheStore } from '../../stores/cachingStore.js'; import { isProduction } from '../../setup.js'; import { deleteAtIndex, insertAtIndex, reorderArray } from '../../utils/arrayUtils.js'; +import { swapOntimeEvents } from 'ontime-utils'; /** * Key of rundown in cache @@ -174,7 +176,33 @@ export async function cachedReorder(eventId: string, from: number, to: number) { export async function cachedClear() { await DataProvider.clearRundown(); runtimeCacheStore.setCached(delayedRundownCacheKey, []); - console.log(DataProvider.getRundown(), getDelayedRundown()); +} + +/** + * Swaps two events + * @param {string} fromEventId + * @param {string} toEventId + */ +export async function cachedSwap(fromEventId: string, toEventId: string) { + const fromEventIndex = DataProvider.getIndexOf(fromEventId); + const toEventIndex = DataProvider.getIndexOf(toEventId); + + const rundown = DataProvider.getRundown(); + const rundownToUpdate = swapOntimeEvents(rundown, fromEventIndex, toEventIndex); + + const delayedRundown = getDelayedRundown(); + const fromCachedEvent = delayedRundown.at(fromEventIndex); + const toCachedEvent = delayedRundown.at(toEventIndex); + + if (fromCachedEvent.id !== fromEventId || toCachedEvent.id !== toEventId) { + // something went wrong, we invalidate the cache + runtimeCacheStore.invalidate(delayedRundownCacheKey); + } else { + const delayedRundownToUpdate = swapOntimeEvents(delayedRundown, fromEventIndex, toEventIndex); + runtimeCacheStore.setCached(delayedRundownCacheKey, delayedRundownToUpdate); + } + + await DataProvider.setRundown(rundownToUpdate); } /** diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 358d33256..775ef91c4 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -4,6 +4,7 @@ export { validatePlayback } from './src/validate-action/validatePlayback.js'; // rundown utils export { generateId } from './src/generate-id/generateId.js'; export { calculateDuration } from './src/rundown-utils/rundownUtils.js'; +export { swapOntimeEvents } from './src/rundown-utils/rundownUtils.js'; // format utils export { formatDisplay } from './src/date-utils/formatDisplay.js'; diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index b5e101dfa..f7bd9ac22 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -1,3 +1,5 @@ +import { OntimeEvent, OntimeRundown } from 'ontime-types'; + import { dayInMs } from '../timeConstants.js'; /** @@ -13,3 +15,43 @@ export const calculateDuration = (timeStart: number, timeEnd: number): number => } return timeEnd - timeStart; }; + +/** + * @description swaps two OntimeEvents in the rundown + * @param {OntimeRundown} rundown + * @param {number} fromEventIndex + * @param {number} toEventIndex + * @returns {OntimeRundown} + */ +export const swapOntimeEvents = ( + rundown: OntimeRundown, + fromEventIndex: number, + toEventIndex: number, +): OntimeRundown => { + const updatedRundown = [...rundown]; + + if (fromEventIndex < 0 || toEventIndex < 0) { + throw new Error('ID not found at index'); + } + + const fromEvent = updatedRundown.at(fromEventIndex) as OntimeEvent; + const toEvent = updatedRundown.at(toEventIndex) as OntimeEvent; + + updatedRundown[fromEventIndex] = { + ...toEvent, + timeStart: fromEvent.timeStart, + timeEnd: fromEvent.timeEnd, + duration: fromEvent.duration, + delay: fromEvent.delay, + }; + + updatedRundown[toEventIndex] = { + ...fromEvent, + timeStart: toEvent.timeStart, + timeEnd: toEvent.timeEnd, + duration: toEvent.duration, + delay: toEvent.delay, + }; + + return updatedRundown; +};