mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-29 02:49:13 +00:00
refactor: entry actions in cuesheet
fix: insert entry before fix: avoid double submit on enter fix: move events in the rundown fix: clone groups
This commit is contained in:
committed by
Carlos Valente
parent
8ad260d28a
commit
24a3823d3b
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
|
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
|
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
|
||||||
|
|
||||||
interface UseReactiveTextInputReturn {
|
interface UseReactiveTextInputReturn {
|
||||||
@@ -21,6 +21,8 @@ export default function useReactiveTextInput(
|
|||||||
},
|
},
|
||||||
): UseReactiveTextInputReturn {
|
): UseReactiveTextInputReturn {
|
||||||
const [text, setText] = useState<string>(initialText);
|
const [text, setText] = useState<string>(initialText);
|
||||||
|
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
|
||||||
|
const isKeyboardSubmitting = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof initialText === 'undefined') {
|
if (typeof initialText === 'undefined') {
|
||||||
@@ -99,11 +101,25 @@ export default function useReactiveTextInput(
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (options?.submitOnEnter) {
|
if (options?.submitOnEnter) {
|
||||||
hotKeys.push(['Enter', () => handleSubmit(text)]);
|
hotKeys.push(['Enter', () => {
|
||||||
|
isKeyboardSubmitting.current = true;
|
||||||
|
handleSubmit(text);
|
||||||
|
// clear flag after blur has been processed
|
||||||
|
setTimeout(() => {
|
||||||
|
isKeyboardSubmitting.current = false;
|
||||||
|
}, 0);
|
||||||
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.submitOnCtrlEnter) {
|
if (options?.submitOnCtrlEnter) {
|
||||||
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
|
hotKeys.push(['mod + Enter', () => {
|
||||||
|
isKeyboardSubmitting.current = true;
|
||||||
|
handleSubmit(text);
|
||||||
|
// clear flag after blur has been processed
|
||||||
|
setTimeout(() => {
|
||||||
|
isKeyboardSubmitting.current = false;
|
||||||
|
}, 0);
|
||||||
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hotKeyHandler = getHotkeyHandler(hotKeys);
|
const hotKeyHandler = getHotkeyHandler(hotKeys);
|
||||||
@@ -126,7 +142,11 @@ export default function useReactiveTextInput(
|
|||||||
return {
|
return {
|
||||||
value: text,
|
value: text,
|
||||||
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
|
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
|
||||||
onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
|
onBlur: (event: ChangeEvent) => {
|
||||||
|
if (!isKeyboardSubmitting.current) {
|
||||||
|
handleSubmit((event.target as HTMLInputElement).value);
|
||||||
|
}
|
||||||
|
},
|
||||||
onKeyDown: keyHandler,
|
onKeyDown: keyHandler,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export const useEntryActions = () => {
|
|||||||
* Calls mutation to add new entry
|
* Calls mutation to add new entry
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _addEntryMutation = useMutation({
|
const { mutateAsync: addEntryMutation } = useMutation({
|
||||||
// TODO(v4): optimistic create entry
|
// TODO(v4): optimistic create entry
|
||||||
mutationFn: postAddEntry,
|
mutationFn: postAddEntry,
|
||||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||||
@@ -143,13 +143,13 @@ export const useEntryActions = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _addEntryMutation.mutateAsync(newEntry);
|
await addEntryMutation(newEntry);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Failed adding event', error);
|
logAxiosError('Failed adding event', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
_addEntryMutation,
|
addEntryMutation,
|
||||||
defaultDangerTime,
|
defaultDangerTime,
|
||||||
defaultDuration,
|
defaultDuration,
|
||||||
defaultEndAction,
|
defaultEndAction,
|
||||||
@@ -165,7 +165,7 @@ export const useEntryActions = () => {
|
|||||||
* Calls mutation to clone a selection
|
* Calls mutation to clone a selection
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _cloneMutation = useMutation({
|
const { mutateAsync: cloneEntryMutation } = useMutation({
|
||||||
mutationFn: postCloneEntry,
|
mutationFn: postCloneEntry,
|
||||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||||
});
|
});
|
||||||
@@ -176,19 +176,19 @@ export const useEntryActions = () => {
|
|||||||
const clone = useCallback(
|
const clone = useCallback(
|
||||||
async (entryId: EntryId) => {
|
async (entryId: EntryId) => {
|
||||||
try {
|
try {
|
||||||
await _cloneMutation.mutateAsync(entryId);
|
await cloneEntryMutation(entryId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error cloning entry', error);
|
logAxiosError('Error cloning entry', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_cloneMutation],
|
[cloneEntryMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to update existing entry
|
* Calls mutation to update existing entry
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _updateEntryMutation = useMutation({
|
const { mutateAsync: updateEntryMutation } = useMutation({
|
||||||
mutationFn: putEditEntry,
|
mutationFn: putEditEntry,
|
||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async (newEvent) => {
|
onMutate: async (newEvent) => {
|
||||||
@@ -234,12 +234,12 @@ export const useEntryActions = () => {
|
|||||||
const updateEntry = useCallback(
|
const updateEntry = useCallback(
|
||||||
async (event: Partial<OntimeEntry>) => {
|
async (event: Partial<OntimeEntry>) => {
|
||||||
try {
|
try {
|
||||||
await _updateEntryMutation.mutateAsync(event);
|
await updateEntryMutation(event);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error updating event', error);
|
logAxiosError('Error updating event', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_updateEntryMutation],
|
[updateEntryMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateCustomField = useCallback(
|
const updateCustomField = useCallback(
|
||||||
@@ -287,7 +287,7 @@ export const useEntryActions = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _updateEntryMutation.mutateAsync(newEvent);
|
await updateEntryMutation(newEvent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error updating event', error);
|
logAxiosError('Error updating event', error);
|
||||||
}
|
}
|
||||||
@@ -339,14 +339,14 @@ export const useEntryActions = () => {
|
|||||||
return previousEnd;
|
return previousEnd;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_updateEntryMutation, queryClient],
|
[updateEntryMutation, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to edit multiple events
|
* Calls mutation to edit multiple events
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _batchUpdateEventsMutation = useMutation({
|
const { mutateAsync: batchUpdateEventsMutation } = useMutation({
|
||||||
mutationFn: putBatchEditEvents,
|
mutationFn: putBatchEditEvents,
|
||||||
onMutate: async ({ ids, data }) => {
|
onMutate: async ({ ids, data }) => {
|
||||||
// cancel ongoing queries
|
// cancel ongoing queries
|
||||||
@@ -405,19 +405,19 @@ export const useEntryActions = () => {
|
|||||||
const batchUpdateEvents = useCallback(
|
const batchUpdateEvents = useCallback(
|
||||||
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
|
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
|
||||||
try {
|
try {
|
||||||
await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
|
await batchUpdateEventsMutation({ ids: eventIds, data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error updating events', error);
|
logAxiosError('Error updating events', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_batchUpdateEventsMutation],
|
[batchUpdateEventsMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to delete an entry
|
* Calls mutation to delete an entry
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _deleteEntryMutation = useMutation({
|
const { mutateAsync: deleteEntryMutation } = useMutation({
|
||||||
mutationFn: deleteEntries,
|
mutationFn: deleteEntries,
|
||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async (entryIds: EntryId[]) => {
|
onMutate: async (entryIds: EntryId[]) => {
|
||||||
@@ -462,19 +462,19 @@ export const useEntryActions = () => {
|
|||||||
const deleteEntry = useCallback(
|
const deleteEntry = useCallback(
|
||||||
async (entryIds: EntryId[]) => {
|
async (entryIds: EntryId[]) => {
|
||||||
try {
|
try {
|
||||||
await _deleteEntryMutation.mutateAsync(entryIds);
|
await deleteEntryMutation(entryIds);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error deleting event', error);
|
logAxiosError('Error deleting event', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_deleteEntryMutation],
|
[deleteEntryMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to delete all events
|
* Calls mutation to delete all events
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _deleteAllEntriesMutation = useMutation({
|
const { mutateAsync: deleteAllEntriesMutation } = useMutation({
|
||||||
mutationFn: requestDeleteAll,
|
mutationFn: requestDeleteAll,
|
||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async () => {
|
onMutate: async () => {
|
||||||
@@ -514,17 +514,17 @@ export const useEntryActions = () => {
|
|||||||
*/
|
*/
|
||||||
const deleteAllEntries = useCallback(async () => {
|
const deleteAllEntries = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await _deleteAllEntriesMutation.mutateAsync();
|
await deleteAllEntriesMutation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error deleting events', error);
|
logAxiosError('Error deleting events', error);
|
||||||
}
|
}
|
||||||
}, [_deleteAllEntriesMutation]);
|
}, [deleteAllEntriesMutation]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to apply a delay
|
* Calls mutation to apply a delay
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _applyDelayMutation = useMutation({
|
const { mutateAsync: applyDelayMutation } = useMutation({
|
||||||
mutationFn: requestApplyDelay,
|
mutationFn: requestApplyDelay,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
if (!response.data) return;
|
if (!response.data) return;
|
||||||
@@ -551,19 +551,19 @@ export const useEntryActions = () => {
|
|||||||
const applyDelay = useCallback(
|
const applyDelay = useCallback(
|
||||||
async (delayEventId: EntryId) => {
|
async (delayEventId: EntryId) => {
|
||||||
try {
|
try {
|
||||||
await _applyDelayMutation.mutateAsync(delayEventId);
|
await applyDelayMutation(delayEventId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error applying delay', error);
|
logAxiosError('Error applying delay', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_applyDelayMutation],
|
[applyDelayMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to dissolve a block
|
* Calls mutation to dissolve a block
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _ungroupMutation = useMutation({
|
const { mutateAsync: ungroupMutation } = useMutation({
|
||||||
mutationFn: requestUngroup,
|
mutationFn: requestUngroup,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
if (!response.data) return;
|
if (!response.data) return;
|
||||||
@@ -587,19 +587,19 @@ export const useEntryActions = () => {
|
|||||||
const ungroup = useCallback(
|
const ungroup = useCallback(
|
||||||
async (blockId: EntryId) => {
|
async (blockId: EntryId) => {
|
||||||
try {
|
try {
|
||||||
await _ungroupMutation.mutateAsync(blockId);
|
await ungroupMutation(blockId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error dissolving block', error);
|
logAxiosError('Error dissolving block', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_ungroupMutation],
|
[ungroupMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to create a block with a selection
|
* Calls mutation to create a block with a selection
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _groupEntriesMutation = useMutation({
|
const { mutateAsync: groupEntriesMutation } = useMutation({
|
||||||
mutationFn: requestGroupEntries,
|
mutationFn: requestGroupEntries,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
if (!response.data) return;
|
if (!response.data) return;
|
||||||
@@ -623,19 +623,19 @@ export const useEntryActions = () => {
|
|||||||
const groupEntries = useCallback(
|
const groupEntries = useCallback(
|
||||||
async (entryIds: EntryId[]) => {
|
async (entryIds: EntryId[]) => {
|
||||||
try {
|
try {
|
||||||
await _groupEntriesMutation.mutateAsync(entryIds);
|
await groupEntriesMutation(entryIds);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error grouping entries', error);
|
logAxiosError('Error grouping entries', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_groupEntriesMutation],
|
[groupEntriesMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to reorder an entry
|
* Calls mutation to reorder an entry
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _reorderEntryMutation = useMutation({
|
const { mutateAsync: reorderEntryMutation } = useMutation({
|
||||||
mutationFn: patchReorderEntry,
|
mutationFn: patchReorderEntry,
|
||||||
// Mutation finished, failed or successful
|
// Mutation finished, failed or successful
|
||||||
// Fetch anyway, just to be sure
|
// Fetch anyway, just to be sure
|
||||||
@@ -644,6 +644,40 @@ export const useEntryActions = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorders a given entry one step up or down in the timeline
|
||||||
|
*/
|
||||||
|
const move = useCallback(
|
||||||
|
async (entryId: EntryId, direction: 'up' | 'down') => {
|
||||||
|
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
if (!rundown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { destinationId, order } =
|
||||||
|
direction === 'up'
|
||||||
|
? moveUp(entryId, rundown.flatOrder, rundown.entries)
|
||||||
|
: moveDown(entryId, rundown.flatOrder, rundown.entries);
|
||||||
|
|
||||||
|
if (!destinationId) {
|
||||||
|
return; // noop
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reorderObject: ReorderEntry = {
|
||||||
|
entryId,
|
||||||
|
destinationId,
|
||||||
|
order,
|
||||||
|
};
|
||||||
|
await reorderEntryMutation(reorderObject);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error re-ordering event', error);
|
||||||
|
}
|
||||||
|
// the rundown needs to know whether we moved into a block
|
||||||
|
return rundown.entries[destinationId]?.type === 'block' ? destinationId : undefined;
|
||||||
|
},
|
||||||
|
[queryClient, reorderEntryMutation],
|
||||||
|
);
|
||||||
/**
|
/**
|
||||||
* Reorders a given entry
|
* Reorders a given entry
|
||||||
*/
|
*/
|
||||||
@@ -655,43 +689,19 @@ export const useEntryActions = () => {
|
|||||||
destinationId,
|
destinationId,
|
||||||
order,
|
order,
|
||||||
};
|
};
|
||||||
await _reorderEntryMutation.mutateAsync(reorderObject);
|
await reorderEntryMutation(reorderObject);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error re-ordering event', error);
|
logAxiosError('Error re-ordering event', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_reorderEntryMutation],
|
[reorderEntryMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const move = useCallback(async (entryId: EntryId, direction: 'up' | 'down') => {
|
|
||||||
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
|
||||||
if (!cachedRundown?.order) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { destinationId, order } =
|
|
||||||
direction === 'up'
|
|
||||||
? moveUp(entryId, cachedRundown.order, cachedRundown.entries)
|
|
||||||
: moveDown(entryId, cachedRundown.order, cachedRundown.entries);
|
|
||||||
|
|
||||||
if (destinationId) {
|
|
||||||
try {
|
|
||||||
const reorderObject: ReorderEntry = {
|
|
||||||
entryId,
|
|
||||||
destinationId,
|
|
||||||
order: order as 'before' | 'after' | 'insert',
|
|
||||||
};
|
|
||||||
await _reorderEntryMutation.mutateAsync(reorderObject);
|
|
||||||
} catch (error) {
|
|
||||||
logAxiosError('Error re-ordering event', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls mutation to swap events
|
* Calls mutation to swap events
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const _swapEvents = useMutation({
|
const { mutateAsync: swapEventsMutation } = useMutation({
|
||||||
mutationFn: requestEventSwap,
|
mutationFn: requestEventSwap,
|
||||||
// we optimistically update here
|
// we optimistically update here
|
||||||
onMutate: async ({ from, to }) => {
|
onMutate: async ({ from, to }) => {
|
||||||
@@ -745,12 +755,12 @@ export const useEntryActions = () => {
|
|||||||
const swapEvents = useCallback(
|
const swapEvents = useCallback(
|
||||||
async ({ from, to }: SwapEntry) => {
|
async ({ from, to }: SwapEntry) => {
|
||||||
try {
|
try {
|
||||||
await _swapEvents.mutateAsync({ from, to });
|
await swapEventsMutation({ from, to });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logAxiosError('Error re-ordering event', error);
|
logAxiosError('Error re-ordering event', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_swapEvents],
|
[swapEventsMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { MutableRefObject, useCallback, useEffect } from 'react';
|
import { MutableRefObject, useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { useSelectedEventId } from './useSocket';
|
||||||
|
|
||||||
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
||||||
componentRef: MutableRefObject<ComponentRef>,
|
componentRef: MutableRefObject<ComponentRef>,
|
||||||
@@ -16,6 +18,23 @@ function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends H
|
|||||||
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function snapToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
||||||
|
componentRef: MutableRefObject<ComponentRef>,
|
||||||
|
scrollRef: MutableRefObject<ScrollRef>,
|
||||||
|
topOffset: number,
|
||||||
|
) {
|
||||||
|
if (!componentRef.current || !scrollRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentRect = componentRef.current.getBoundingClientRect();
|
||||||
|
const scrollRect = scrollRef.current.getBoundingClientRect();
|
||||||
|
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
|
||||||
|
|
||||||
|
// maintain current x scroll position
|
||||||
|
scrollRef.current.scrollTo(scrollRef.current.scrollLeft, top);
|
||||||
|
}
|
||||||
|
|
||||||
interface UseFollowComponentProps {
|
interface UseFollowComponentProps {
|
||||||
followRef: MutableRefObject<HTMLElement | null>;
|
followRef: MutableRefObject<HTMLElement | null>;
|
||||||
scrollRef: MutableRefObject<HTMLElement | null>;
|
scrollRef: MutableRefObject<HTMLElement | null>;
|
||||||
@@ -62,3 +81,32 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
|
|||||||
|
|
||||||
return scrollToRefComponent;
|
return scrollToRefComponent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useFollowSelected(doFollow: boolean, topOffset = 100) {
|
||||||
|
const selectedEvenId = useSelectedEventId();
|
||||||
|
|
||||||
|
const selectedRef = useRef<HTMLTableRowElement>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doFollow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEvenId && selectedRef.current && scrollRef.current) {
|
||||||
|
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
snapToComponent(
|
||||||
|
{ current: selectedRef.current } as MutableRefObject<HTMLElement>,
|
||||||
|
{ current: scrollRef.current } as MutableRefObject<HTMLElement>,
|
||||||
|
topOffset,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [doFollow, selectedEvenId, topOffset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedRef,
|
||||||
|
scrollRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { cloneEvent } from '../../common/utils/clone';
|
|||||||
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
||||||
import BlockEnd from './rundown-block/BlockEnd';
|
import BlockEnd from './rundown-block/BlockEnd';
|
||||||
import RundownBlock from './rundown-block/RundownBlock';
|
import RundownBlock from './rundown-block/RundownBlock';
|
||||||
import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from './rundown.utils';
|
import { makeRundownMetadata, makeSortableList } from './rundown.utils';
|
||||||
import RundownEmpty from './RundownEmpty';
|
import RundownEmpty from './RundownEmpty';
|
||||||
import { useEventSelection } from './useEventSelection';
|
import { useEventSelection } from './useEventSelection';
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { addEntry, reorderEntry, deleteEntry } = useEntryActions();
|
const { addEntry, deleteEntry, move, reorderEntry } = useEntryActions();
|
||||||
|
|
||||||
const { entryCopyId, setEntryCopyId } = useEntryCopy();
|
const { entryCopyId, setEntryCopyId } = useEntryCopy();
|
||||||
|
|
||||||
@@ -218,26 +218,18 @@ export default function Rundown({ data }: RundownProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const moveEntry = useCallback(
|
const moveEntry = useCallback(
|
||||||
(cursor: EntryId | null, direction: 'up' | 'down') => {
|
async (cursor: EntryId | null, direction: 'up' | 'down') => {
|
||||||
if (sortableData.length < 2 || cursor == null) {
|
if (cursor == null) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { destinationId, order, isBlock } =
|
|
||||||
direction === 'up' ? moveUp(cursor, sortableData, entries) : moveDown(cursor, sortableData, entries);
|
|
||||||
|
|
||||||
if (!destinationId) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const movedIntoBlockId = await move(cursor, direction);
|
||||||
// if we are moving into a block, we need to make sure it is expanded
|
// if we are moving into a block, we need to make sure it is expanded
|
||||||
if (isBlock) {
|
if (movedIntoBlockId) {
|
||||||
handleCollapseGroup(false, destinationId);
|
handleCollapseGroup(false, movedIntoBlockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderEntry(cursor, destinationId, order as 'before' | 'after' | 'insert');
|
|
||||||
},
|
},
|
||||||
[sortableData, entries, reorderEntry, handleCollapseGroup],
|
[handleCollapseGroup, move],
|
||||||
);
|
);
|
||||||
|
|
||||||
// shortcuts
|
// shortcuts
|
||||||
|
|||||||
@@ -334,66 +334,199 @@ describe('makeSortableList()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe('moveUp()', () => {
|
describe('moveUp()', () => {
|
||||||
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
|
const rundown = {
|
||||||
const entries = {
|
entries: {
|
||||||
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
|
'1': { id: '1', type: 'event', parent: null } as OntimeEvent,
|
||||||
event2: { type: 'event', id: 'event2', parent: null } as OntimeEvent,
|
'2': { id: '2', type: 'event', parent: null } as OntimeEvent,
|
||||||
block1: { type: 'block', id: 'block1', entries: ['event3'] } as OntimeBlock,
|
'3': { id: '3', type: 'event', parent: null } as OntimeEvent,
|
||||||
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
|
block: { id: 'block', type: 'block', entries: ['11', '12'] } as OntimeBlock,
|
||||||
block2: { type: 'block', id: 'block2', entries: [] as EntryId[] } as OntimeBlock,
|
'11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent,
|
||||||
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
|
'12': { id: '12', type: 'event', parent: 'block' } as OntimeEvent,
|
||||||
|
'4': { id: '4', type: 'event', parent: null } as OntimeEvent,
|
||||||
|
block2: { id: 'block2', type: 'block', entries: [] as EntryId[] } as OntimeBlock,
|
||||||
|
'5': { id: '5', type: 'event', parent: null } as OntimeEvent,
|
||||||
|
},
|
||||||
|
order: ['1', '2', '3', 'block', '4', 'block2', '5'],
|
||||||
|
flatOrder: ['1', '2', '3', 'block', '11', '12', '4', 'block2', '5'],
|
||||||
};
|
};
|
||||||
|
|
||||||
it('moves an event up in the list', () => {
|
it('moving the first event is a noop', () => {
|
||||||
const result = moveUp('event2', sortableData, entries);
|
expect(moveUp('1', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
expect(result).toStrictEqual({ destinationId: 'event1', order: 'before', isBlock: false });
|
destinationId: null,
|
||||||
|
order: 'before',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.todo('disallows nesting blocks', () => {
|
it('moves an entry up in the rundown', () => {
|
||||||
const result = moveUp('block2', sortableData, entries);
|
expect(moveUp('2', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
expect(result).toStrictEqual({ destinationId: 'block1', order: 'before', isBlock: false });
|
destinationId: '1',
|
||||||
|
order: 'before',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moves an event into a block', () => {
|
it('moves an entry up inside a block', () => {
|
||||||
const result = moveUp('event3', sortableData, entries);
|
expect(moveUp('12', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
expect(result).toStrictEqual({ destinationId: 'block2', order: 'insert', isBlock: true });
|
destinationId: '11',
|
||||||
|
order: 'before',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moving up from top is noop', () => {
|
it('moves an entry up into an empty group', () => {
|
||||||
const result = moveUp('event1', sortableData, entries);
|
expect(moveUp('5', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
expect(result).toMatchObject({ destinationId: null });
|
destinationId: 'block2',
|
||||||
|
order: 'insert',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an entry up into a group', () => {
|
||||||
|
expect(moveUp('4', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
|
destinationId: '12',
|
||||||
|
order: 'after',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an entry up out of a group', () => {
|
||||||
|
expect(moveUp('11', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
|
destinationId: 'block',
|
||||||
|
order: 'before',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a block in the rundown', () => {
|
||||||
|
expect(moveUp('block', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
|
destinationId: '3',
|
||||||
|
order: 'before',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps two blocks', () => {
|
||||||
|
const rundown = {
|
||||||
|
entries: {
|
||||||
|
block: { id: 'block', type: 'block', entries: ['11'] } as OntimeBlock,
|
||||||
|
'11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent,
|
||||||
|
block2: { id: 'block2', type: 'block', entries: [] as EntryId[] } as OntimeBlock,
|
||||||
|
},
|
||||||
|
order: ['block', 'block2'],
|
||||||
|
flatOrder: ['block', '11', 'block2'],
|
||||||
|
};
|
||||||
|
expect(moveUp('block2', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
|
destinationId: 'block',
|
||||||
|
order: 'before',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves before a block', () => {
|
||||||
|
const rundown = {
|
||||||
|
entries: {
|
||||||
|
block: { id: 'block', type: 'block', entries: ['11'] } as OntimeBlock,
|
||||||
|
'11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent,
|
||||||
|
},
|
||||||
|
order: ['block'],
|
||||||
|
flatOrder: ['block', '11'],
|
||||||
|
};
|
||||||
|
expect(moveUp('11', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
|
destinationId: 'block',
|
||||||
|
order: 'before',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('moveDown()', () => {
|
describe('moveDown()', () => {
|
||||||
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
|
const rundown = {
|
||||||
const entries = {
|
entries: {
|
||||||
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
|
'1': { id: '1', type: 'event', parent: null } as OntimeEvent,
|
||||||
event2: { type: 'event', id: 'event2', parent: null } as OntimeEvent,
|
'2': { id: '2', type: 'event', parent: null } as OntimeEvent,
|
||||||
block1: { type: 'block', id: 'block1', entries: ['event11'] } as OntimeBlock,
|
'3': { id: '3', type: 'event', parent: null } as OntimeEvent,
|
||||||
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
|
block: { id: 'block', type: 'block', entries: ['11', '12'] } as OntimeBlock,
|
||||||
block2: { type: 'block', id: 'block2', entries: [] as EntryId[] } as OntimeBlock,
|
'11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent,
|
||||||
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
|
'12': { id: '12', type: 'event', parent: 'block' } as OntimeEvent,
|
||||||
|
'4': { id: '4', type: 'event', parent: null } as OntimeEvent,
|
||||||
|
block2: { id: 'block2', type: 'block', entries: [] as EntryId[] } as OntimeBlock,
|
||||||
|
'5': { id: '5', type: 'event', parent: null } as OntimeEvent,
|
||||||
|
},
|
||||||
|
order: ['1', '2', '3', 'block', '4', 'block2', '5'],
|
||||||
|
flatOrder: ['1', '2', '3', 'block', '11', '12', '4', 'block2', '5'],
|
||||||
};
|
};
|
||||||
|
|
||||||
it('moves an event down in the list', () => {
|
it('moving the last event is a noop', () => {
|
||||||
const result = moveDown('event1', sortableData, entries);
|
expect(moveDown('5', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
expect(result).toStrictEqual({ destinationId: 'event2', order: 'after', isBlock: false });
|
destinationId: null,
|
||||||
|
order: 'after',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.todo('disallows nesting blocks', () => {
|
it('moves an entry down in the rundown', () => {
|
||||||
const result = moveDown('block1', sortableData, entries);
|
expect(moveDown('2', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
expect(result).toStrictEqual({ destinationId: 'block2', order: 'before', isBlock: false });
|
destinationId: '3',
|
||||||
|
order: 'after',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moves an event into a block', () => {
|
it('moves an entry down inside a block', () => {
|
||||||
const result = moveDown('event2', sortableData, entries);
|
expect(moveDown('11', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
expect(result).toStrictEqual({ destinationId: 'event11', order: 'before', isBlock: true });
|
destinationId: '12',
|
||||||
|
order: 'after',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moving down from bottom is noop', () => {
|
it('moves an entry down into an empty group', () => {
|
||||||
const result = moveDown('event3', sortableData, entries);
|
expect(moveDown('4', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
expect(result).toMatchObject({ destinationId: null });
|
destinationId: 'block2',
|
||||||
|
order: 'insert',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an entry down out of a group', () => {
|
||||||
|
expect(moveDown('12', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
|
destinationId: '4',
|
||||||
|
order: 'after',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an entry down into a group', () => {
|
||||||
|
expect(moveDown('3', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
|
destinationId: '11',
|
||||||
|
order: 'before',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a block in the rundown', () => {
|
||||||
|
expect(moveDown('block', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
|
destinationId: '4',
|
||||||
|
order: 'after',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps two blocks', () => {
|
||||||
|
const rundown = {
|
||||||
|
entries: {
|
||||||
|
block: { id: 'block', type: 'block', entries: ['11'] } as OntimeBlock,
|
||||||
|
'11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent,
|
||||||
|
block2: { id: 'block2', type: 'block', entries: [] as EntryId[] } as OntimeBlock,
|
||||||
|
},
|
||||||
|
order: ['block', 'block2'],
|
||||||
|
flatOrder: ['block', '11', 'block2'],
|
||||||
|
};
|
||||||
|
expect(moveDown('block', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
|
destinationId: 'block2',
|
||||||
|
order: 'after',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves after a block', () => {
|
||||||
|
const rundown = {
|
||||||
|
entries: {
|
||||||
|
block: { id: 'block', type: 'block', entries: ['11'] } as OntimeBlock,
|
||||||
|
'11': { id: '11', type: 'event', parent: 'block' } as OntimeEvent,
|
||||||
|
},
|
||||||
|
order: ['block'],
|
||||||
|
flatOrder: ['block', '11'],
|
||||||
|
};
|
||||||
|
expect(moveDown('11', rundown.flatOrder, rundown.entries)).toStrictEqual({
|
||||||
|
destinationId: 'block',
|
||||||
|
order: 'after',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -165,101 +165,136 @@ export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates destinations for an entry moving one position up in the rundown
|
* calculates destinations for an entry moving one position up in the rundown
|
||||||
* - Handles noops
|
* @returns An object describing how to move the entry:
|
||||||
* - Handles moving in and out of blocks
|
* - destinationId: The target entry ID (null if no movement possible)
|
||||||
* TODO: handle moving blocks
|
* - order: How to position relative to the destination:
|
||||||
|
* - 'before': Place before the destination
|
||||||
|
* - 'after': Place after the destination
|
||||||
|
* - 'insert': Insert into the destination (for blocks)
|
||||||
*/
|
*/
|
||||||
export function moveUp(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) {
|
export function moveUp(
|
||||||
const previousEntryId = getPreviousId(entryId, sortableData);
|
entryId: EntryId,
|
||||||
|
flatOrder: EntryId[],
|
||||||
|
entries: RundownEntries,
|
||||||
|
): { destinationId: EntryId | null; order: 'before' | 'after' | 'insert' } {
|
||||||
|
const currentEntry = entries[entryId];
|
||||||
|
const currentIndex = flatOrder.indexOf(entryId);
|
||||||
|
const previousEntryId = flatOrder[currentIndex - 1];
|
||||||
|
|
||||||
// the user is moving up at the top of the list
|
// 1. moving at the top of the list
|
||||||
if (!previousEntryId) {
|
if (!previousEntryId) {
|
||||||
return { destinationId: null, order: 'before', isBlock: false };
|
// 1a. we are in a block and need to move outside of it
|
||||||
}
|
if ('parent' in currentEntry && currentEntry.parent !== null) {
|
||||||
|
return { destinationId: currentEntry.parent, order: 'before' };
|
||||||
if (previousEntryId.startsWith('end-')) {
|
|
||||||
const entry = entries[entryId];
|
|
||||||
if (isOntimeBlock(entry)) {
|
|
||||||
// if we are moving a block, we cannot insert it
|
|
||||||
return { destinationId: previousEntryId.replace('end-', ''), order: 'before', isBlock: false };
|
|
||||||
}
|
}
|
||||||
// insert in the block ID will add to the end of the block events
|
// 1b. we are at the start of the rundown, no movement possible
|
||||||
return { destinationId: previousEntryId.replace('end-', ''), order: 'insert', isBlock: true };
|
return { destinationId: null, order: 'before' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-expect-error -- we safeguard the entry not having a parent property
|
// 2. moving a block (always moves at top level)
|
||||||
return { destinationId: previousEntryId, order: 'before', isBlock: Boolean(entries[previousEntryId]?.parent) };
|
if (isOntimeBlock(currentEntry)) {
|
||||||
|
// 21. if previous entry is inside a block, swap with parent
|
||||||
|
const previousEntry = entries[previousEntryId];
|
||||||
|
if ('parent' in previousEntry && previousEntry.parent !== null) {
|
||||||
|
return { destinationId: previousEntry.parent, order: 'before' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. previous entry is at top level, we just swap places
|
||||||
|
return { destinationId: previousEntryId, order: 'before' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousEntry = entries[previousEntryId];
|
||||||
|
const currentEntryParent = currentEntry.parent;
|
||||||
|
|
||||||
|
// 3. moving in and out of a block
|
||||||
|
if (isOntimeBlock(previousEntry)) {
|
||||||
|
// 3a. if we're not already in the block, move into it
|
||||||
|
if (currentEntryParent === null) {
|
||||||
|
return { destinationId: previousEntryId, order: 'insert' };
|
||||||
|
}
|
||||||
|
// 3b. otherwise, move before the block
|
||||||
|
return { destinationId: previousEntryId, order: 'before' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. moving into the same block as previous entry
|
||||||
|
if (isOntimeEvent(previousEntry) && previousEntry.parent !== null && currentEntryParent === null) {
|
||||||
|
return { destinationId: previousEntryId, order: 'after' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// default - swap positions with previous entry
|
||||||
|
return { destinationId: previousEntryId, order: 'before' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates destinations for an entry moving one position down in the rundown
|
* calculates destinations for an entry moving one position down in the rundown
|
||||||
* - Handles noops
|
* @returns An object describing how to move the entry:
|
||||||
* - Handles moving in and out of blocks
|
* - destinationId: The target entry ID (null if no movement possible)
|
||||||
* TODO: handle moving blocks
|
* - order: How to position relative to the destination:
|
||||||
|
* - 'before': Place before the destination
|
||||||
|
* - 'after': Place after the destination
|
||||||
|
* - 'insert': Insert into the destination (for blocks)
|
||||||
*/
|
*/
|
||||||
export function moveDown(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) {
|
export function moveDown(
|
||||||
const nextEntryId = getNextId(entryId, sortableData);
|
entryId: EntryId,
|
||||||
|
flatOrder: EntryId[],
|
||||||
|
entries: RundownEntries,
|
||||||
|
): { destinationId: EntryId | null; order: 'before' | 'after' | 'insert' } {
|
||||||
|
const currentEntry = entries[entryId];
|
||||||
|
const currentIndex = flatOrder.indexOf(entryId);
|
||||||
|
const nextEntryId = flatOrder[currentIndex + 1];
|
||||||
|
|
||||||
// the user is moving down at the end of the list
|
// 1. moving at the top of the list
|
||||||
if (!nextEntryId) {
|
if (!nextEntryId) {
|
||||||
return { destinationId: null, order: 'after', isBlock: false };
|
// 1a. we are in a block and need to move outside of it
|
||||||
}
|
if ('parent' in currentEntry && currentEntry.parent !== null) {
|
||||||
|
return { destinationId: currentEntry.parent, order: 'after' };
|
||||||
if (nextEntryId.startsWith('end-')) {
|
|
||||||
// move outside the block
|
|
||||||
return { destinationId: nextEntryId.replace('end-', ''), order: 'after', isBlock: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If the next entry is a block
|
|
||||||
* - 1. blocks need to skip over it
|
|
||||||
* - 2. if the block has children, we insert before the first child
|
|
||||||
* - 3. if the block is empty, we insert into the block
|
|
||||||
*/
|
|
||||||
if (isOntimeBlock(entries[nextEntryId])) {
|
|
||||||
const entry = entries[entryId];
|
|
||||||
|
|
||||||
if (isOntimeBlock(entry)) {
|
|
||||||
// 1. if we are moving a block, we cannot insert it
|
|
||||||
return { destinationId: nextEntryId, order: 'after', isBlock: false };
|
|
||||||
}
|
}
|
||||||
|
// 1b. we are at the end of the rundown, no movement possible
|
||||||
|
return { destinationId: null, order: 'after' };
|
||||||
|
}
|
||||||
|
|
||||||
const firstBlockChild = entries[nextEntryId].entries.at(0);
|
// 2. moving a block (always moves at top level)
|
||||||
if (firstBlockChild) {
|
if (isOntimeBlock(currentEntry)) {
|
||||||
// 2. add before the first child of the block
|
// if next entry is inside this block, skip past all children
|
||||||
return { destinationId: firstBlockChild, order: 'before', isBlock: true };
|
if (currentEntry.entries.includes(nextEntryId)) {
|
||||||
} else {
|
const afterBlockIndex = currentIndex + currentEntry.entries.length + 1;
|
||||||
// 3. or insert into an empty block
|
const afterBlockId = flatOrder[afterBlockIndex];
|
||||||
return { destinationId: nextEntryId, order: 'insert', isBlock: true };
|
|
||||||
|
// 2a. block is the last top level entry
|
||||||
|
if (!afterBlockId) {
|
||||||
|
return { destinationId: null, order: 'after' };
|
||||||
|
}
|
||||||
|
// 2b. move after the next top level event
|
||||||
|
return { destinationId: afterBlockId, order: 'after' };
|
||||||
|
}
|
||||||
|
// 2c. empty block move after the next entry
|
||||||
|
return { destinationId: nextEntryId, order: 'after' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEntry = entries[nextEntryId];
|
||||||
|
const currentEntryParent = currentEntry.parent;
|
||||||
|
|
||||||
|
// 3. handle moving relative to blocks
|
||||||
|
if (isOntimeBlock(nextEntry)) {
|
||||||
|
if (currentEntryParent === null) {
|
||||||
|
// we are entering a block
|
||||||
|
if (nextEntry.entries.length === 0) {
|
||||||
|
// 3a. if the block is empty, insert into it
|
||||||
|
return { destinationId: nextEntryId, order: 'insert' };
|
||||||
|
}
|
||||||
|
// 3b. otherwise, add before the first entry in the block
|
||||||
|
const firstBlockEntryId = nextEntry.entries[0];
|
||||||
|
return { destinationId: firstBlockEntryId, order: 'before' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { destinationId: nextEntryId, order: 'after', isBlock: Boolean(entries[nextEntryId]?.parent) };
|
// 4. handle moving between block and top level
|
||||||
}
|
const nextEntryParent = isOntimeEvent(nextEntry) ? nextEntry.parent : null;
|
||||||
|
if (nextEntryParent !== null && currentEntryParent === null) {
|
||||||
/**
|
return { destinationId: nextEntryId, order: 'after' };
|
||||||
* Utility function gets the ID if the next entry in the list
|
|
||||||
* returns null if none is found
|
|
||||||
*/
|
|
||||||
function getNextId(entryId: EntryId, sortableData: EntryId[]): EntryId | null {
|
|
||||||
const currentIndex = sortableData.indexOf(entryId);
|
|
||||||
if (currentIndex === -1 || currentIndex === sortableData.length - 1) {
|
|
||||||
// No next ID if not found or at the end
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
return sortableData[currentIndex + 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// default - swap positions with next entry
|
||||||
* Utility function gets the ID if the previous entry in the list
|
return { destinationId: nextEntryId, order: 'after' };
|
||||||
* returns null if none is found
|
|
||||||
*/
|
|
||||||
function getPreviousId(entryId: EntryId, sortableData: EntryId[]): EntryId | null {
|
|
||||||
const currentIndex = sortableData.indexOf(entryId);
|
|
||||||
if (currentIndex < 1) {
|
|
||||||
// No previous ID found or at the beginning
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return sortableData[currentIndex - 1];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
import { useTableNav } from '@table-nav/react';
|
import { useTableNav } from '@table-nav/react';
|
||||||
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
import { OntimeEntry, TimeField } from 'ontime-types';
|
import { OntimeEntry, TimeField } from 'ontime-types';
|
||||||
|
|
||||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||||
import useFollowComponent from '../../../common/hooks/useFollowComponent';
|
import { useFollowSelected } from '../../../common/hooks/useFollowComponent';
|
||||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||||
|
|
||||||
import CuesheetBody from './cuesheet-table-elements/CuesheetBody';
|
import CuesheetBody from './cuesheet-table-elements/CuesheetBody';
|
||||||
@@ -26,9 +26,7 @@ export default function CuesheetTable({ data, columns }: CuesheetTableProps) {
|
|||||||
const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes);
|
const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes);
|
||||||
const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds);
|
const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds);
|
||||||
|
|
||||||
const selectedRef = useRef<HTMLTableRowElement | null>(null);
|
const { selectedRef, scrollRef } = useFollowSelected(followPlayback);
|
||||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followPlayback });
|
|
||||||
|
|
||||||
const { listeners } = useTableNav();
|
const { listeners } = useTableNav();
|
||||||
|
|
||||||
@@ -106,12 +104,13 @@ export default function CuesheetTable({ data, columns }: CuesheetTableProps) {
|
|||||||
const headers = table.getFlatHeaders();
|
const headers = table.getFlatHeaders();
|
||||||
const colSizes: { [key: string]: number } = {};
|
const colSizes: { [key: string]: number } = {};
|
||||||
for (let i = 0; i < headers.length; i++) {
|
for (let i = 0; i < headers.length; i++) {
|
||||||
const header = headers[i]!;
|
const header = headers[i];
|
||||||
|
if (!header) continue;
|
||||||
colSizes[`--header-${header.id}-size`] = header.getSize();
|
colSizes[`--header-${header.id}-size`] = header.getSize();
|
||||||
colSizes[`--col-${header.column.id}-size`] = header.column.getSize();
|
colSizes[`--col-${header.column.id}-size`] = header.column.getSize();
|
||||||
}
|
}
|
||||||
return colSizes;
|
return colSizes;
|
||||||
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
|
}, [table]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -121,7 +120,7 @@ export default function CuesheetTable({ data, columns }: CuesheetTableProps) {
|
|||||||
handleResetReordering={resetColumnOrder}
|
handleResetReordering={resetColumnOrder}
|
||||||
handleClearToggles={setAllVisible}
|
handleClearToggles={setAllVisible}
|
||||||
/>
|
/>
|
||||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
<div className={style.cuesheetContainer} ref={scrollRef}>
|
||||||
<table className={style.cuesheet} id='cuesheet' style={{ ...columnSizeVars }} {...listeners}>
|
<table className={style.cuesheet} id='cuesheet' style={{ ...columnSizeVars }} {...listeners}>
|
||||||
<CuesheetHeader headerGroups={headerGroups} />
|
<CuesheetHeader headerGroups={headerGroups} />
|
||||||
{table.getState().columnSizingInfo.isResizingColumn ? (
|
{table.getState().columnSizingInfo.isResizingColumn ? (
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function BlockRow({ blockId, colour, hidePast, rowId, rowIndex, t
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const yPos = 8 + rect.y + rect.height / 2;
|
const yPos = 8 + rect.y + rect.height / 2;
|
||||||
openMenu({ x: rect.x, y: yPos }, blockId, rowIndex);
|
openMenu({ x: rect.x, y: yPos }, blockId, rowIndex, null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IoEllipsisHorizontal />
|
<IoEllipsisHorizontal />
|
||||||
|
|||||||
+4
-6
@@ -1,4 +1,4 @@
|
|||||||
import { MutableRefObject, useMemo } from 'react';
|
import { RefObject, useMemo } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { RowModel, Table } from '@tanstack/react-table';
|
import { RowModel, Table } from '@tanstack/react-table';
|
||||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeBlock, OntimeEntry, Rundown } from 'ontime-types';
|
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeBlock, OntimeEntry, Rundown } from 'ontime-types';
|
||||||
@@ -17,7 +17,7 @@ import { useVisibleRowsStore } from './visibleRowsStore';
|
|||||||
|
|
||||||
interface CuesheetBodyProps {
|
interface CuesheetBodyProps {
|
||||||
rowModel: RowModel<OntimeEntry>;
|
rowModel: RowModel<OntimeEntry>;
|
||||||
selectedRef: MutableRefObject<HTMLTableRowElement | null>;
|
selectedRef: RefObject<HTMLTableRowElement>;
|
||||||
table: Table<OntimeEntry>;
|
table: Table<OntimeEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +106,6 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
|||||||
const isSelected = key === selectedEventId;
|
const isSelected = key === selectedEventId;
|
||||||
const columnHash = getColumnHash();
|
const columnHash = getColumnHash();
|
||||||
|
|
||||||
|
|
||||||
if (isPast && hidePast) {
|
if (isPast && hidePast) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -129,14 +128,13 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
|||||||
let firstAfterBlock = false;
|
let firstAfterBlock = false;
|
||||||
if (entry.parent) {
|
if (entry.parent) {
|
||||||
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
const parentEntry = rundown?.entries[entry.parent];
|
const parentEntry = rundown?.entries[entry.parent] as OntimeBlock | undefined;
|
||||||
parentBgColour = (parentEntry as OntimeBlock).colour;
|
parentBgColour = parentEntry?.colour;
|
||||||
hadBlock = true;
|
hadBlock = true;
|
||||||
} else if (hadBlock) {
|
} else if (hadBlock) {
|
||||||
firstAfterBlock = true;
|
firstAfterBlock = true;
|
||||||
hadBlock = false;
|
hadBlock = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventRow
|
<EventRow
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, MutableRefObject, useLayoutEffect, useRef } from 'react';
|
import { MutableRefObject, useLayoutEffect, useRef } from 'react';
|
||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
import { flexRender, Table } from '@tanstack/react-table';
|
import { flexRender, Table } from '@tanstack/react-table';
|
||||||
import { OntimeEntry, OntimeEvent, RGBColour } from 'ontime-types';
|
import { OntimeEntry, OntimeEvent, RGBColour } from 'ontime-types';
|
||||||
@@ -31,21 +31,7 @@ interface EventRowProps {
|
|||||||
firstAfterBlock: boolean;
|
firstAfterBlock: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(EventRow, (prevProps, nextProps) => {
|
export default function EventRow({
|
||||||
return (
|
|
||||||
prevProps.rowId === nextProps.rowId &&
|
|
||||||
prevProps.event.revision === nextProps.event.revision &&
|
|
||||||
prevProps.eventIndex === nextProps.eventIndex &&
|
|
||||||
prevProps.rowIndex === nextProps.rowIndex &&
|
|
||||||
prevProps.isPast === nextProps.isPast &&
|
|
||||||
prevProps.selectedRef === nextProps.selectedRef &&
|
|
||||||
prevProps.rowBgColour === nextProps.rowBgColour &&
|
|
||||||
prevProps.parentBgColour === nextProps.parentBgColour &&
|
|
||||||
prevProps.columnHash === nextProps.columnHash
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function EventRow({
|
|
||||||
rowId,
|
rowId,
|
||||||
event,
|
event,
|
||||||
eventIndex,
|
eventIndex,
|
||||||
@@ -87,7 +73,12 @@ function EventRow({
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
id={rowId}
|
id={rowId}
|
||||||
className={cx([style.eventRow, event.skip && style.skip, firstAfterBlock && style.firstAfterBlock, Boolean(parentBgColour) && style.hasParent])}
|
className={cx([
|
||||||
|
style.eventRow,
|
||||||
|
event.skip && style.skip,
|
||||||
|
firstAfterBlock && style.firstAfterBlock,
|
||||||
|
Boolean(parentBgColour) && style.hasParent,
|
||||||
|
])}
|
||||||
style={{
|
style={{
|
||||||
opacity: `${isPast ? '0.2' : '1'}`,
|
opacity: `${isPast ? '0.2' : '1'}`,
|
||||||
'--user-bg': parentBgColour ?? 'transparent',
|
'--user-bg': parentBgColour ?? 'transparent',
|
||||||
@@ -103,7 +94,7 @@ function EventRow({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const yPos = 8 + rect.y + rect.height / 2;
|
const yPos = 8 + rect.y + rect.height / 2;
|
||||||
openMenu({ x: rect.x, y: yPos }, event.id, rowIndex);
|
openMenu({ x: rect.x, y: yPos }, event.id, rowIndex, event.parent);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IoEllipsisHorizontal />
|
<IoEllipsisHorizontal />
|
||||||
|
|||||||
+16
-29
@@ -1,10 +1,9 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5';
|
import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5';
|
||||||
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList, Portal } from '@chakra-ui/react';
|
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList, Portal } from '@chakra-ui/react';
|
||||||
import { isOntimeEvent, SupportedEntry } from 'ontime-types';
|
import { SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
|
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
|
||||||
import { cloneEvent } from '../../../../common/utils/clone';
|
|
||||||
import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal';
|
import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal';
|
||||||
|
|
||||||
import { useCuesheetTableMenu } from './useCuesheetTableMenu';
|
import { useCuesheetTableMenu } from './useCuesheetTableMenu';
|
||||||
@@ -12,28 +11,10 @@ import { useCuesheetTableMenu } from './useCuesheetTableMenu';
|
|||||||
export default memo(CuesheetTableMenu);
|
export default memo(CuesheetTableMenu);
|
||||||
|
|
||||||
function CuesheetTableMenu() {
|
function CuesheetTableMenu() {
|
||||||
const { isOpen, eventId, entryIndex, position, closeMenu } = useCuesheetTableMenu();
|
const { isOpen, entryId, entryIndex, parentId, position, closeMenu } = useCuesheetTableMenu();
|
||||||
const { addEntry, getEntryById, move, deleteEntry } = useEntryActions();
|
const { addEntry, clone, deleteEntry, move } = useEntryActions();
|
||||||
const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
|
const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
|
||||||
|
|
||||||
const handleCloneEvent = () => {
|
|
||||||
if (!eventId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentEvent = getEntryById(eventId);
|
|
||||||
if (!currentEvent || !isOntimeEvent(currentEvent)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEvent = cloneEvent(currentEvent);
|
|
||||||
try {
|
|
||||||
addEntry(newEvent, { after: eventId });
|
|
||||||
} catch (_error) {
|
|
||||||
// we do not handle errors here
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -48,27 +29,33 @@ function CuesheetTableMenu() {
|
|||||||
h={1}
|
h={1}
|
||||||
/>
|
/>
|
||||||
<MenuList>
|
<MenuList>
|
||||||
<MenuItem icon={<IoOptions />} onClick={() => showModal(eventId)}>
|
<MenuItem icon={<IoOptions />} onClick={() => showModal(entryId)}>
|
||||||
Edit ...
|
Edit ...
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { before: eventId })}>
|
<MenuItem
|
||||||
|
icon={<IoAdd />}
|
||||||
|
onClick={() => addEntry({ type: SupportedEntry.Event, parent: parentId }, { before: entryId })}
|
||||||
|
>
|
||||||
Add event above
|
Add event above
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { after: eventId })}>
|
<MenuItem
|
||||||
|
icon={<IoAdd />}
|
||||||
|
onClick={() => addEntry({ type: SupportedEntry.Event, parent: parentId }, { after: entryId })}
|
||||||
|
>
|
||||||
Add event below
|
Add event below
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem icon={<IoDuplicateOutline />} onClick={handleCloneEvent}>
|
<MenuItem icon={<IoDuplicateOutline />} onClick={() => clone(entryId)}>
|
||||||
Clone event
|
Clone event
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
<MenuItem isDisabled={entryIndex < 1} icon={<IoArrowUp />} onClick={() => move(eventId, 'up')}>
|
<MenuItem isDisabled={entryIndex < 1} icon={<IoArrowUp />} onClick={() => move(entryId, 'up')}>
|
||||||
Move up
|
Move up
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem icon={<IoArrowDown />} onClick={() => move(eventId, 'down')}>
|
<MenuItem icon={<IoArrowDown />} onClick={() => move(entryId, 'down')}>
|
||||||
Move down
|
Move down
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem icon={<IoTrash />} onClick={() => deleteEntry([eventId])}>
|
<MenuItem icon={<IoTrash />} onClick={() => deleteEntry([entryId])}>
|
||||||
Delete
|
Delete
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</MenuList>
|
</MenuList>
|
||||||
|
|||||||
+10
-6
@@ -1,31 +1,35 @@
|
|||||||
|
import { EntryId } from 'ontime-types';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
type Anchor = { x: number; y: number };
|
type Anchor = { x: number; y: number };
|
||||||
|
|
||||||
type OpenMenu = {
|
type OpenMenu = {
|
||||||
isOpen: true;
|
isOpen: true;
|
||||||
eventId: string;
|
entryId: EntryId;
|
||||||
entryIndex: number;
|
entryIndex: number;
|
||||||
|
parentId: EntryId | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClosedMenu = {
|
type ClosedMenu = {
|
||||||
isOpen: false;
|
isOpen: false;
|
||||||
eventId: null;
|
entryId: null;
|
||||||
entryIndex: null;
|
entryIndex: null;
|
||||||
|
parentId: null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CuesheetTableMenuStore = (OpenMenu | ClosedMenu) & {
|
type CuesheetTableMenuStore = (OpenMenu | ClosedMenu) & {
|
||||||
position: Anchor;
|
position: Anchor;
|
||||||
openMenu: (position: Anchor, eventId: string, entryIndex: number) => void;
|
openMenu: (position: Anchor, entryId: EntryId, entryIndex: number, parentId: EntryId | null) => void;
|
||||||
closeMenu: () => void;
|
closeMenu: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCuesheetTableMenu = create<CuesheetTableMenuStore>((set) => ({
|
export const useCuesheetTableMenu = create<CuesheetTableMenuStore>((set) => ({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
eventId: null,
|
entryId: null,
|
||||||
entryIndex: null,
|
entryIndex: null,
|
||||||
|
parentId: null,
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
openMenu: (position: Anchor, eventId: string, entryIndex: number) =>
|
openMenu: (position: Anchor, entryId: EntryId, entryIndex: number, parentId: EntryId | null) =>
|
||||||
set({ isOpen: true, position, eventId, entryIndex }),
|
set({ isOpen: true, position, entryId, entryIndex, parentId }),
|
||||||
closeMenu: () => set({ isOpen: false }),
|
closeMenu: () => set({ isOpen: false }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -699,7 +699,7 @@ describe('rundownMutation.add()', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
rundownMutation.add(rundown, mockEvent, null, '1');
|
rundownMutation.add(rundown, mockEvent, null, rundown.entries['1'] as OntimeBlock);
|
||||||
|
|
||||||
expect(rundown.order).toStrictEqual(['1']);
|
expect(rundown.order).toStrictEqual(['1']);
|
||||||
expect(rundown.flatOrder).toStrictEqual(['1', 'mock', '1a']);
|
expect(rundown.flatOrder).toStrictEqual(['1', 'mock', '1a']);
|
||||||
@@ -717,7 +717,7 @@ describe('rundownMutation.add()', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
rundownMutation.add(rundown, mockEvent, '1a', '1');
|
rundownMutation.add(rundown, mockEvent, '1a', rundown.entries['1'] as OntimeBlock);
|
||||||
|
|
||||||
expect(rundown.order).toStrictEqual(['1']);
|
expect(rundown.order).toStrictEqual(['1']);
|
||||||
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
|
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
|
||||||
@@ -1058,6 +1058,40 @@ describe('rundownMutation.reorder()', () => {
|
|||||||
parent: '2',
|
parent: '2',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('moves a block (up)', () => {
|
||||||
|
const rundown = makeRundown({
|
||||||
|
order: ['1', '2'],
|
||||||
|
flatOrder: ['1', '11', '2', '22'],
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeBlock({ id: '1', entries: ['11'] }),
|
||||||
|
'11': makeOntimeEvent({ id: '11', parent: '1' }),
|
||||||
|
'2': makeOntimeBlock({ id: '2', entries: ['22'] }),
|
||||||
|
'22': makeOntimeEvent({ id: '22', parent: '2' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
rundownMutation.reorder(rundown, rundown.entries['2'], rundown.entries['1'], 'before');
|
||||||
|
|
||||||
|
expect(rundown.order).toStrictEqual(['2', '1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a block (down)', () => {
|
||||||
|
const rundown = makeRundown({
|
||||||
|
order: ['1', '2'],
|
||||||
|
flatOrder: ['1', '11', '2', '22'],
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeBlock({ id: '1', entries: ['11'] }),
|
||||||
|
'11': makeOntimeEvent({ id: '11', parent: '1' }),
|
||||||
|
'2': makeOntimeBlock({ id: '2', entries: ['22'] }),
|
||||||
|
'22': makeOntimeEvent({ id: '22', parent: '2' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
rundownMutation.reorder(rundown, rundown.entries['1'], rundown.entries['2'], 'after');
|
||||||
|
|
||||||
|
expect(rundown.order).toStrictEqual(['2', '1']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rundownMutation.applyDelay()', () => {
|
describe('rundownMutation.applyDelay()', () => {
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
|
import { TimeStrategy, EndAction, TimerType, OntimeEvent, OntimeBlock } from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
import { assertType } from 'vitest';
|
import { assertType } from 'vitest';
|
||||||
|
|
||||||
import { calculateDayOffset, createEvent, deleteById, doesInvalidateMetadata, getInsertAfterId, hasChanges } from '../rundown.utils.js';
|
import {
|
||||||
import { makeRundown } from '../__mocks__/rundown.mocks.js';
|
calculateDayOffset,
|
||||||
|
createEvent,
|
||||||
|
deleteById,
|
||||||
|
doesInvalidateMetadata,
|
||||||
|
getInsertAfterId,
|
||||||
|
hasChanges,
|
||||||
|
} from '../rundown.utils.js';
|
||||||
|
import { makeOntimeBlock, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
describe('test event validator', () => {
|
describe('test event validator', () => {
|
||||||
it('validates a good object', () => {
|
it('validates a good object', () => {
|
||||||
@@ -217,22 +224,39 @@ describe('calculateDayOffset()', () => {
|
|||||||
|
|
||||||
describe('getInsertAfterId()', () => {
|
describe('getInsertAfterId()', () => {
|
||||||
const rundown = makeRundown({
|
const rundown = makeRundown({
|
||||||
flatOrder: ['a', 'b', 'c', 'd'],
|
entries: {
|
||||||
|
'1': makeOntimeEvent({ id: '1', parent: null }),
|
||||||
|
'2': makeOntimeEvent({ id: '2', parent: null }),
|
||||||
|
block: makeOntimeBlock({ id: 'block', entries: ['31', '32'] }),
|
||||||
|
'31': makeOntimeEvent({ id: '31', parent: 'block' }),
|
||||||
|
'32': makeOntimeEvent({ id: '32', parent: 'block' }),
|
||||||
|
'4': makeOntimeEvent({ id: '31', parent: null }),
|
||||||
|
},
|
||||||
|
order: ['1', '2', 'block', '4'],
|
||||||
|
flatOrder: ['1', '2', 'block', '31', '32', '4'],
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns afterId if provided', () => {
|
it('returns afterId if provided', () => {
|
||||||
expect(getInsertAfterId(rundown, 'b')).toBe('b');
|
expect(getInsertAfterId(rundown, null, 'b')).toBe('b');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the previous id before beforeId if provided', () => {
|
it('returns null if neither afterId nor beforeId is provided', () => {
|
||||||
expect(getInsertAfterId(rundown, undefined, 'c')).toBe('b');
|
expect(getInsertAfterId(rundown, null)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined if neither afterId nor beforeId is provided', () => {
|
it('returns null if beforeId is not found', () => {
|
||||||
expect(getInsertAfterId(rundown)).toBeNull();
|
expect(getInsertAfterId(rundown, null, undefined, 'z')).toBeNull();
|
||||||
|
expect(getInsertAfterId(rundown, null, undefined, '1')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined if beforeId is not found', () => {
|
it('returns the previous id of an entry in the rundown', () => {
|
||||||
expect(getInsertAfterId(rundown, undefined, 'z')).toBeNull();
|
expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('1');
|
||||||
|
expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('block');
|
||||||
|
expect(getInsertAfterId(rundown, null, undefined, 'block')).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the previous id of an event in a block', () => {
|
||||||
|
expect(getInsertAfterId(rundown, rundown.entries.block as OntimeBlock, undefined, '31')).toBeNull();
|
||||||
|
expect(getInsertAfterId(rundown, rundown.entries.block as OntimeBlock, undefined, '32')).toBe('31');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -189,18 +189,17 @@ export function createTransaction(options: TransactionOptions): Transaction {
|
|||||||
* - 2a. add entry to the rundown, after a given entry
|
* - 2a. add entry to the rundown, after a given entry
|
||||||
* - 2b. add entry to the rundown, at the beginning
|
* - 2b. add entry to the rundown, at the beginning
|
||||||
*/
|
*/
|
||||||
function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parentId: EntryId | null): OntimeEntry {
|
function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parent: OntimeBlock | null): OntimeEntry {
|
||||||
if (parentId) {
|
if (parent) {
|
||||||
// 1. inserting an entry inside a block
|
// 1. inserting an entry inside a block
|
||||||
const parentBlock = rundown.entries[parentId] as OntimeBlock;
|
|
||||||
if (afterId) {
|
if (afterId) {
|
||||||
const atEventsIndex = parentBlock.entries.indexOf(afterId) + 1;
|
const atEventsIndex = parent.entries.indexOf(afterId) + 1;
|
||||||
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
||||||
parentBlock.entries = insertAtIndex(atEventsIndex, entry.id, parentBlock.entries);
|
parent.entries = insertAtIndex(atEventsIndex, entry.id, parent.entries);
|
||||||
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
||||||
} else {
|
} else {
|
||||||
parentBlock.entries = insertAtIndex(0, entry.id, parentBlock.entries);
|
parent.entries = insertAtIndex(0, entry.id, parent.entries);
|
||||||
const atFlatIndex = rundown.flatOrder.indexOf(parentId) + 1;
|
const atFlatIndex = rundown.flatOrder.indexOf(parent.id) + 1;
|
||||||
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -282,6 +281,8 @@ function removeAll(rundown: Rundown): Rundown {
|
|||||||
/**
|
/**
|
||||||
* Reorders an entry in the rundown
|
* Reorders an entry in the rundown
|
||||||
* Handle moving across order lists
|
* Handle moving across order lists
|
||||||
|
* @param order - 'before' | 'after' | 'insert' - where to add the entry, insert serves to add the entry into an empty block
|
||||||
|
* @throws if we insert a block inside another
|
||||||
*/
|
*/
|
||||||
function reorder(rundown: Rundown, eventFrom: OntimeEntry, eventTo: OntimeEntry, order: 'before' | 'after' | 'insert') {
|
function reorder(rundown: Rundown, eventFrom: OntimeEntry, eventTo: OntimeEntry, order: 'before' | 'after' | 'insert') {
|
||||||
// handle moving across parents
|
// handle moving across parents
|
||||||
@@ -289,6 +290,10 @@ function reorder(rundown: Rundown, eventFrom: OntimeEntry, eventTo: OntimeEntry,
|
|||||||
const toParent = (() => {
|
const toParent = (() => {
|
||||||
if (isOntimeBlock(eventTo)) {
|
if (isOntimeBlock(eventTo)) {
|
||||||
if (order === 'insert') {
|
if (order === 'insert') {
|
||||||
|
// prevent blocks from being inserted into other blocks
|
||||||
|
if (isOntimeBlock(eventFrom)) {
|
||||||
|
throw new Error('Cannot insert a block into another block');
|
||||||
|
}
|
||||||
return eventTo.id;
|
return eventTo.id;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -296,7 +301,8 @@ function reorder(rundown: Rundown, eventFrom: OntimeEntry, eventTo: OntimeEntry,
|
|||||||
return eventTo.parent ?? null;
|
return eventTo.parent ?? null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
if (!isOntimeBlock(eventFrom)) {
|
// always update the parent when moving entries
|
||||||
|
if ('parent' in eventFrom) {
|
||||||
eventFrom.parent = toParent;
|
eventFrom.parent = toParent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +475,8 @@ function clone(rundown: Rundown, entry: OntimeEntry): OntimeEntry {
|
|||||||
|
|
||||||
return newBlock;
|
return newBlock;
|
||||||
} else {
|
} else {
|
||||||
return add(rundown, cloneEntry(entry, getUniqueId(rundown)), entry.id, entry.parent);
|
const parent: OntimeBlock | null = entry.parent ? (rundown.entries[entry.parent] as OntimeBlock) : null;
|
||||||
|
return add(rundown, cloneEntry(entry, getUniqueId(rundown)), entry.id, parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
isOntimeBlock,
|
isOntimeBlock,
|
||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
|
OntimeBlock,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
PatchWithId,
|
PatchWithId,
|
||||||
@@ -35,23 +36,24 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
|
|||||||
}
|
}
|
||||||
|
|
||||||
// if the user provides a parent (inside a group), we make sure it exists and it is a group
|
// if the user provides a parent (inside a group), we make sure it exists and it is a group
|
||||||
let parent: EntryId | null = null;
|
let parent: OntimeBlock | null = null;
|
||||||
if ('parent' in eventData && eventData.parent != null) {
|
if ('parent' in eventData && eventData.parent != null) {
|
||||||
const maybeParent = rundown.entries[eventData.parent];
|
const maybeParent = rundown.entries[eventData.parent];
|
||||||
if (!maybeParent || !isOntimeBlock(maybeParent)) {
|
if (!maybeParent || !isOntimeBlock(maybeParent)) {
|
||||||
throw new Error(`Invalid parent event with ID ${eventData.parent}`);
|
throw new Error(`Invalid parent event with ID ${eventData.parent}`);
|
||||||
}
|
}
|
||||||
parent = eventData.parent;
|
parent = maybeParent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalise the position of the event in the rundown order
|
// normalise the position of the event in the rundown order
|
||||||
const afterId = getInsertAfterId(rundown, eventData?.after, eventData?.before);
|
const afterId = getInsertAfterId(rundown, parent, eventData?.after, eventData?.before);
|
||||||
|
|
||||||
// generate a fully formed entry from the patch
|
// generate a fully formed entry from the patch
|
||||||
const newEntry = generateEvent(rundown, eventData, afterId);
|
const newEntry = generateEvent(rundown, eventData, afterId);
|
||||||
|
|
||||||
// make mutations to rundown
|
// make mutations to rundown
|
||||||
rundownMutation.add(rundown, newEntry, afterId, parent);
|
rundownMutation.add(rundown, newEntry, afterId, parent);
|
||||||
|
|
||||||
const { rundownMetadata, revision } = commit();
|
const { rundownMetadata, revision } = commit();
|
||||||
|
|
||||||
// schedule the side effects
|
// schedule the side effects
|
||||||
|
|||||||
@@ -381,18 +381,25 @@ export function calculateDayOffset(
|
|||||||
* Receives an insertion order and returns the reference to an event ID
|
* Receives an insertion order and returns the reference to an event ID
|
||||||
* after which we will insert the new event
|
* after which we will insert the new event
|
||||||
*/
|
*/
|
||||||
export function getInsertAfterId(rundown: Rundown, afterId?: EntryId, beforeId?: EntryId): EntryId | null {
|
export function getInsertAfterId(
|
||||||
if (afterId) {
|
rundown: Rundown,
|
||||||
return afterId;
|
parent: OntimeBlock | null,
|
||||||
}
|
afterId?: EntryId,
|
||||||
|
beforeId?: EntryId,
|
||||||
|
): EntryId | null {
|
||||||
|
if (afterId) return afterId;
|
||||||
|
if (!beforeId) return null;
|
||||||
|
|
||||||
if (beforeId) {
|
/**
|
||||||
const atIndex = rundown.flatOrder.findIndex((id) => id === beforeId);
|
* At this point we know we want to insert before a given ID
|
||||||
if (atIndex < 1) return null;
|
* We need to check which list we should use to insert and find the event there
|
||||||
return rundown.flatOrder[atIndex - 1];
|
*/
|
||||||
}
|
const insertionList = parent ? parent.entries : rundown.order;
|
||||||
|
if (!insertionList || insertionList.length === 0) return null;
|
||||||
|
|
||||||
return null;
|
const atIndex = insertionList.findIndex((id) => id === beforeId);
|
||||||
|
if (atIndex < 1) return null;
|
||||||
|
return insertionList[atIndex - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user