Compare commits

..

7 Commits

Author SHA1 Message Date
Carlos Valente e40a0eebec feat: add multi-copy paste support 2026-03-21 09:37:15 +01:00
Joel Wetzell 6a40c66a7b upgrade vite and related dependencies (#2003) 2026-03-20 20:04:39 +01:00
Carlos Valente 2806177c72 fix: prevent wrapping in time inputs 2026-03-17 21:21:34 +01:00
Carlos Valente 4408bdb0d3 refactor: improve shutdown cleanup 2026-03-17 20:11:45 +01:00
Alex Christoffer Rasmussen 871a6b46c8 feat: automatic refetch css override (#1588)
* feat: send refect on css change

* feat: revamp ViewLoader

---------

Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
2026-03-17 20:08:33 +01:00
Carlos Valente fd39cab845 fix: prevent sheet name too long in excel export 2026-03-16 19:05:32 +01:00
Carlos Valente 1cee679397 fix: pending roll sets group expected times as due 2026-03-14 08:43:22 +01:00
42 changed files with 1566 additions and 1078 deletions
+7 -6
View File
@@ -57,20 +57,21 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "^2.16.1",
"@sentry/vite-plugin": "5.1.1",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "4.5.1",
"@vitejs/plugin-react": "5.2.0",
"babel-plugin-react-compiler": "1.0.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "6.3.1",
"vite-plugin-compression2": "2.2.0",
"vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "5.1.4",
"vite": "7.3.1",
"vite-plugin-compression2": "2.5.1",
"vite-plugin-prismjs-plus": "1.1.0",
"vite-plugin-svgr": "4.5.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "catalog:"
}
}
+1 -2
View File
@@ -13,6 +13,7 @@ export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CSS_OVERRIDE = ['cssOverride'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
@@ -21,9 +22,7 @@ export const TRANSLATION = ['translation'];
export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user';
const cssOverridePath = 'styles/override.css';
const customTranslationsPath = 'translations/translations.json';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
+14
View File
@@ -153,6 +153,20 @@ export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
}
export type PastePayload = {
entryIds: EntryId[];
sourceRundownId: string;
afterId?: EntryId;
beforeId?: EntryId;
};
/**
* HTTP request for pasting entries into the active rundown
*/
export async function postPasteEntries(rundownId: RundownId, data: PastePayload): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/paste`, data);
}
/**
* HTTP request for grouping a list of entries into a group
*/
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { getCSSContents } from '../api/assets';
import { CSS_OVERRIDE } from '../api/constants';
export default function useCssOverride(enabled: boolean) {
const { data, status } = useQuery({
queryKey: CSS_OVERRIDE,
queryFn: ({ signal }) => getCSSContents({ signal }),
staleTime: MILLIS_PER_HOUR,
enabled
});
return {
data: data ?? '', status
};
}
@@ -35,11 +35,13 @@ import { useCallback, useMemo } from 'react';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { RUNDOWN } from '../api/constants';
import {
PastePayload,
ReorderEntry,
deleteEntries,
patchReorderEntry,
postAddEntry,
postCloneEntry,
postPasteEntries,
putBatchEditEvents,
putEditEntry,
requestApplyDelay,
@@ -270,6 +272,35 @@ export const useEntryActions = () => {
[cloneEntryMutation, getCurrentRundownData],
);
/**
* Calls mutation to paste entries
* @private
*/
const { mutateAsync: pasteEntriesMutation } = useMutation({
mutationFn: ([rundownId, data]: [string, PastePayload]) => postPasteEntries(rundownId, data),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Paste entries from copy store into the active rundown
*/
const pasteEntries = useCallback(
async (data: PastePayload) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await pasteEntriesMutation([rundownId, data]);
} catch (error) {
logAxiosError('Error pasting entries', error);
}
},
[pasteEntriesMutation, getCurrentRundownData],
);
/**
* Calls mutation to update existing entry
* @private
@@ -919,6 +950,7 @@ export const useEntryActions = () => {
clone,
deleteEntry,
deleteAllEntries,
pasteEntries,
ungroup,
getEntryById,
groupEntries,
@@ -935,6 +967,7 @@ export const useEntryActions = () => {
clone,
deleteEntry,
deleteAllEntries,
pasteEntries,
ungroup,
getEntryById,
groupEntries,
+8 -6
View File
@@ -139,7 +139,9 @@ export const setEventPlayback = {
pause: () => sendSocket('pause', undefined),
};
export const useTimer = createSelector((state: RuntimeStore) => state.timer);
export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
export const useClock = createSelector((state: RuntimeStore) => state.clock);
@@ -200,20 +202,20 @@ export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
groupExpectedEnd: state.offset.expectedGroupEnd,
// we can force these numbers to 0 for this use case to avoid null checks
actualGroupStart: state.rundown.actualGroupStart ?? 0,
actualGroupStart: state.rundown.actualGroupStart,
currentDay: state.rundown.currentDay ?? 0,
playback: state.timer.playback,
phase: state.timer.phase,
}));
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
clock: state.clock,
mode: state.offset.mode,
// we can force these numbers to 0 for this use case to avoid null checks
actualStart: state.rundown.actualStart ?? 0,
plannedStart: state.rundown.plannedStart ?? 0,
actualStart: state.rundown.actualStart,
plannedStart: state.rundown.plannedStart,
currentDay: state.rundown.currentDay ?? 0,
playback: state.timer.playback,
phase: state.timer.phase,
}));
/* ======================= View specific subscriptions ======================= */
@@ -1,14 +1,16 @@
import { create } from 'zustand';
type EntryCopyStore = {
entryCopyId: string | null;
entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
entryIds: Set<string>;
sourceRundownId: string | null;
setCopyEntries: (ids: string[], rundownId: string) => void;
clearCopy: () => void;
};
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null,
entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
entryIds: new Set(),
sourceRundownId: null,
setCopyEntries: (ids: string[], rundownId: string) =>
set({ entryIds: new Set(ids), sourceRundownId: rundownId }),
clearCopy: () => set({ entryIds: new Set(), sourceRundownId: null }),
}));
+4
View File
@@ -13,6 +13,7 @@ import { isProduction, websocketUrl } from '../../externals';
import {
APP_SETTINGS,
CLIENT_LIST,
CSS_OVERRIDE,
CUSTOM_FIELDS,
PROJECT_DATA,
REPORT,
@@ -199,6 +200,9 @@ export const connectSocket = () => {
case RefetchKey.ViewSettings:
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
break;
case RefetchKey.CssOverride:
ontimeQueryClient.invalidateQueries({ queryKey: CSS_OVERRIDE });
break;
case RefetchKey.Translation:
ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
break;
@@ -1,4 +1,4 @@
import Prism from 'prismjs/components/prism-core';
import Prism from 'virtual:prismjs';
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from 'react';
import Editor from 'react-simple-code-editor';
import 'prismjs/components/prism-css';
@@ -1,5 +0,0 @@
declare module 'prismjs/components/prism-core' {
export * from 'prismjs';
}
declare module 'prismjs/components/prism-css';
@@ -208,26 +208,28 @@ export function MetadataTimes() {
}
function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback } = useGroupTimerOverView();
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null;
const active = isPlaybackActive(playback);
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback) ;
// the group end time dose not encode any day offsets so it is calculated with group start time and duration
// the group end time does not encode any day offsets so it is calculated with group start time and duration
const plannedGroupEnd = (() => {
if (!active) return null;
if (!hasRunningTimer) return null;
if (!group || group.timeStart === null) return null;
const normalizedClock = clock + currentDay * dayInMs;
return mode === OffsetMode.Absolute
? group.timeStart + group.duration - normalizedClock
: actualGroupStart + group.duration - normalizedClock;
if (mode === OffsetMode.Absolute) {
return group.timeStart + group.duration - normalizedClock;
}
if (actualGroupStart === null) return null;
return actualGroupStart + group.duration - normalizedClock;
})();
const plannedTimeUntilGroupEnd = formatDueTime(plannedGroupEnd, 3, TimerType.CountDown);
const expectedGroupEnd = groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
const expectedGroupEnd = hasRunningTimer && groupExpectedEnd !== null ? groupExpectedEnd - clock : null;
const expectedTimeUntilGroupEnd = formatDueTime(expectedGroupEnd, 3, TimerType.CountDown);
return (
@@ -238,7 +240,7 @@ function GroupTimes() {
<span
className={cx([
style.time,
(!group || !active) && style.muted,
(!group || !hasRunningTimer) && style.muted,
plannedTimeUntilGroupEnd === 'due' && style.dueTime,
])}
>
@@ -250,7 +252,7 @@ function GroupTimes() {
<span
className={cx([
style.time,
!groupExpectedEnd && style.muted,
expectedGroupEnd === null && style.muted,
expectedTimeUntilGroupEnd === 'due' && style.dueTime,
])}
>
@@ -262,25 +264,27 @@ function GroupTimes() {
}
function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay } = useFlagTimerOverView();
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag();
const entry = useEntry(id) as OntimeEvent | null;
const active = isPlaybackActive(playback);
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
const plannedFlagStart = (() => {
if (!active) return null;
if (!hasRunningTimer) return null;
if (!entry) return null;
const normalizedTimeStart = entry.timeStart + entry.dayOffset * dayInMs;
const normalizedClock = clock + currentDay * dayInMs;
return mode === OffsetMode.Absolute
? normalizedTimeStart - normalizedClock
: normalizedTimeStart + actualStart - plannedStart - normalizedClock;
if (mode === OffsetMode.Absolute) {
return normalizedTimeStart - normalizedClock;
}
if (actualStart === null || plannedStart === null) return null;
return normalizedTimeStart + actualStart - plannedStart - normalizedClock;
})();
const plannedTimeUntilDisplay = formatDueTime(plannedFlagStart, 3, TimerType.CountDown);
const expectedTimeUntil = expectedStart !== null ? expectedStart - clock : null;
const expectedTimeUntil = hasRunningTimer && expectedStart !== null ? expectedStart - clock : null;
const expectedTimeUntilDisplay = formatDueTime(expectedTimeUntil, 3, TimerType.CountDown);
const title = entry?.title ?? null;
@@ -294,7 +298,7 @@ function FlagTimes() {
data-testid='flag-plannedStart'
className={cx([
style.time,
(!entry || !active) && style.muted,
(!entry || !hasRunningTimer) && style.muted,
plannedTimeUntilDisplay === 'due' && style.dueTime,
])}
>
+3 -3
View File
@@ -13,7 +13,6 @@ import { TbFlagFilled } from 'react-icons/tb';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { RundownMetadataObject, lastMetadataKey } from '../../common/utils/rundownMetadata';
import { AppMode } from '../../ontimeConfig';
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
@@ -64,7 +63,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
const entryActions = useEntryActionsContext();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
const selectedEvents = useEventSelection((state) => state.selectedEvents);
// cursor
const { editorMode } = useEditorFollowMode();
@@ -105,9 +104,10 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
// Keyboard shortcuts
useRundownKeyboard({
cursor,
rundownId: id,
selectedEvents,
commands,
clearSelectedEvents,
setEntryCopyId,
});
// DND handlers
@@ -96,14 +96,6 @@ function EventEditorEmpty() {
<Kbd>C</Kbd>
</td>
</tr>
<tr>
<td>Cut selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>X</Kbd>
</td>
</tr>
<tr>
<td>Paste above</td>
<td>
@@ -26,7 +26,7 @@ export function useRundownCommands({
selectEntry: applySelection,
handleCollapseGroup,
}: UseRundownCommandsOptions) {
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
const { addEntry, clone, deleteEntry, move, pasteEntries } = entryActions;
const deleteAtCursor = useCallback(
(cursor: string | null) => {
@@ -40,52 +40,21 @@ export function useRundownCommands({
[entries, flatOrder, deleteEntry, applySelection],
);
const insertCopyAtId = useCallback(
(atId: EntryId | null, above = false) => {
// lazily get the value from the store
const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection
const pasteAtCursor = useCallback(
(cursor: EntryId | null, above = false) => {
const { entryIds, sourceRundownId } = useEntryCopy.getState();
if (entryIds.size === 0 || !sourceRundownId) {
return;
}
let normalisedAtId = atId;
const elementToCopy = entries[entryCopyId];
const refElement = atId ? entries[atId] : undefined;
if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) {
normalisedAtId = refElement.parent;
}
if (entryCopyMode === 'cut') {
if (!normalisedAtId) {
const firstId = flatOrder[0];
if (!firstId || firstId === entryCopyId) {
return;
}
reorderEntry(entryCopyId, firstId, 'before')
.then(() => setEntryCopyId(null))
.catch(() => {});
return;
}
if (normalisedAtId === entryCopyId) {
return;
}
const placement = above ? 'before' : 'after';
reorderEntry(entryCopyId, normalisedAtId, placement)
.then(() => setEntryCopyId(null))
.catch(() => {});
return;
}
clone(entryCopyId, {
after: above ? undefined : (normalisedAtId ?? undefined),
// if we don't have a cursor add the new event on top
before: above ? (normalisedAtId ?? undefined) : undefined,
pasteEntries({
entryIds: Array.from(entryIds),
sourceRundownId,
afterId: above ? undefined : (cursor ?? undefined),
beforeId: above ? (cursor ?? undefined) : undefined,
});
},
[entries, flatOrder, clone, reorderEntry],
[pasteEntries],
);
/**
@@ -217,7 +186,7 @@ export function useRundownCommands({
return {
cloneEntry,
deleteAtCursor,
insertCopyAtId,
pasteAtCursor,
insertAtId,
selectGroup,
selectEntry,
@@ -6,6 +6,8 @@ import { useEventSelection } from '../useEventSelection';
interface UseRundownKeyboardOptions {
cursor: EntryId | null;
rundownId: string | undefined;
selectedEvents: Set<EntryId>;
commands: {
selectEntry: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null;
selectGroup: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null;
@@ -15,10 +17,9 @@ interface UseRundownKeyboardOptions {
moveEntry: (cursor: EntryId | null, direction: 'up' | 'down') => void;
deleteAtCursor: (cursor: EntryId | null) => void;
insertAtId: (patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: EntryId | null, above?: boolean) => void;
insertCopyAtId: (atId: EntryId | null, above?: boolean) => void;
pasteAtCursor: (cursor: EntryId | null, above?: boolean) => void;
};
clearSelectedEvents: () => void;
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
}
/**
@@ -37,9 +38,10 @@ function isEditableElement(target: EventTarget | null): boolean {
export function useRundownKeyboard({
cursor,
rundownId,
selectedEvents,
commands,
clearSelectedEvents,
setEntryCopyId,
}: UseRundownKeyboardOptions) {
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
@@ -138,7 +140,7 @@ export function useRundownKeyboard({
'Escape',
() => {
clearSelectedEvents();
setEntryCopyId(null);
useEntryCopy.getState().clearCopy();
},
{ preventDefault: true, usePhysicalKeys: true },
],
@@ -192,33 +194,25 @@ export function useRundownKeyboard({
[
'mod + C',
(event) => {
if (cursor === null || isEditableElement(event.target)) {
if (cursor === null || isEditableElement(event.target) || !rundownId) {
return;
}
event.preventDefault();
setEntryCopyId(cursor);
},
{ usePhysicalKeys: true },
],
[
'mod + X',
(event) => {
if (cursor === null || isEditableElement(event.target)) {
return;
}
event.preventDefault();
setEntryCopyId(cursor, 'cut');
// if multiple events are selected, copy all of them; otherwise copy the cursor entry
const ids = selectedEvents.size > 1 ? Array.from(selectedEvents) : [cursor];
useEntryCopy.getState().setCopyEntries(ids, rundownId);
},
{ usePhysicalKeys: true },
],
[
'mod + V',
(event) => {
if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) {
if (isEditableElement(event.target) || useEntryCopy.getState().entryIds.size === 0) {
return;
}
event.preventDefault();
commands.insertCopyAtId(cursor);
commands.pasteAtCursor(cursor);
},
{ usePhysicalKeys: true },
],
@@ -226,11 +220,11 @@ export function useRundownKeyboard({
[
'mod + shift + V',
(event) => {
if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) {
if (isEditableElement(event.target) || useEntryCopy.getState().entryIds.size === 0) {
return;
}
event.preventDefault();
commands.insertCopyAtId(cursor, true);
commands.pasteAtCursor(cursor, true);
},
{ usePhysicalKeys: true },
],
@@ -17,10 +17,6 @@
outline: 1px solid $block-cursor-color;
}
&.copyTarget {
outline: 1px dashed $blue-500;
outline-offset: -2px;
}
}
.drag {
@@ -6,7 +6,6 @@ import { IoCheckmarkDone, IoClose, IoReorderTwo } from 'react-icons/io5';
import Button from '../../../common/components/buttons/Button';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { cx } from '../../../common/utils/styleUtils';
import DelayInput from './DelayInput';
@@ -22,7 +21,6 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
const { applyDelay, deleteEntry } = useEntryActionsContext();
const handleRef = useRef<null | HTMLSpanElement>(null);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const {
attributes: dragAttributes,
@@ -61,7 +59,7 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
return (
<div
className={cx([style.delay, hasCursor && style.hasCursor, entryCopyId === data.id && style.copyTarget])}
className={cx([style.delay, hasCursor && style.hasCursor])}
ref={setNodeRef}
style={dragStyle}
data-testid='rundown-delay'
@@ -58,10 +58,6 @@ $skip-opacity: 0.2;
outline: 1px solid $block-cursor-color;
}
&.copyTarget {
outline: 2px dashed $block-cursor-color;
}
&.past:not(.skip) {
.timerNote,
.statusElements,
@@ -17,7 +17,6 @@ import { TbFlagFilled } from 'react-icons/tb';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventIdSwapping } from '../useEventIdSwapping';
@@ -106,7 +105,6 @@ export default function RundownEvent({
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -248,7 +246,6 @@ export default function RundownEvent({
playback && style[playback],
isSelected && style.selected,
hasCursor && style.hasCursor,
entryCopyId === eventId && style.copyTarget,
]);
const handleFocusClick = (event: MouseEvent) => {
@@ -13,10 +13,6 @@
outline: 1px solid $block-cursor-color;
}
&.copyTarget {
outline: 2px dashed $block-cursor-color;
}
&.expanded {
margin-block: 0.5rem 0;
border-radius: $block-border-radius $block-border-radius 0 0;
@@ -16,7 +16,6 @@ import IconButton from '../../../common/components/buttons/IconButton';
import Tag from '../../../common/components/tag/Tag';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils';
import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils';
@@ -43,7 +42,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
{
@@ -133,7 +131,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
style.group,
hasCursor && style.hasCursor,
!collapsed && style.expanded,
entryCopyId === data.id && style.copyTarget,
])}
ref={setNodeRef}
onClick={handleFocusClick}
@@ -18,9 +18,6 @@
outline: 1px solid $block-cursor-color;
}
&.copyTarget {
outline: 2px dashed $block-cursor-color;
}
}
.binder {
@@ -8,7 +8,6 @@ import Input from '../../../common/components/input/input/Input';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventSelection } from '../useEventSelection';
@@ -31,7 +30,6 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
{
@@ -89,7 +87,6 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
className={cx([
style.milestone,
hasCursor ? style.hasCursor : null,
entryCopyId === entryId ? style.copyTarget : null,
])}
ref={setNodeRef}
onClick={handleFocusClick}
+26 -22
View File
@@ -1,35 +1,39 @@
import { PropsWithChildren } from 'react';
import { PropsWithChildren, Suspense } from 'react';
import { overrideStylesURL } from '../common/api/constants';
import useCssOverride from '../common/hooks-query/useCssOverride';
import useViewSettings from '../common/hooks-query/useViewSettings';
import { useRuntimeStylesheet } from '../common/hooks/useRuntimeStylesheet';
import Loader from './common/loader/Loader';
export default function ViewLoader({ children }: PropsWithChildren) {
const { data } = useViewSettings();
const { shouldRender } = useRuntimeStylesheet(data.overrideStyles ? overrideStylesURL : undefined);
const scriptTagId = 'ontime-stylesheet-override';
function OverrideStyles() {
'use memo';
const { data: settings } = useViewSettings();
const { overrideStyles } = settings;
const { data: css } = useCssOverride(overrideStyles);
const cssBlob = URL.createObjectURL(new Blob([css], { type: 'text/css' }));
//@ts-expect-error disabled exists on link when rel='stylesheet' https://react.dev/reference/react-dom/components/link#props
return <link id={scriptTagId} rel='stylesheet' href={cssBlob} precedence='high' disabled={!overrideStyles} />;
}
export default function ViewLoader({ children }: PropsWithChildren) {
'use memo';
// we need to be able to override the background colour with the key param
const searchParams = new URLSearchParams(window.location.search);
const colourFromParams = searchParams.get('keyColour') ?? '#101010';
// eventually we would want to leverage suspense here
// while the feature is not ready, we simply trigger a loader
// suspense would have the advantage of being triggered also by react-query
if (!shouldRender) {
return (
<>
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
<Loader />
</>
);
}
return (
<>
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
<Suspense
fallback={
<>
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
<Loader />
</>
}
>
<OverrideStyles />
{children}
</>
</Suspense>
);
}
@@ -4,6 +4,7 @@
background-color: transparent;
border-radius: $component-border-radius-md;
text-wrap: nowrap;
white-space: nowrap;
display: flex;
align-items: center;
+2 -1
View File
@@ -9,7 +9,8 @@
],
"types": [
"vite/client",
"vite-plugin-svgr/client"
"vite-plugin-svgr/client",
"vite-plugin-prismjs-plus/client",
],
"module": "esnext",
"moduleResolution": "bundler",
+6 -10
View File
@@ -4,6 +4,7 @@ import { sentryVitePlugin } from '@sentry/vite-plugin';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { compression } from 'vite-plugin-compression2';
import prismjsPlugin from 'vite-plugin-prismjs-plus';
import svgrPlugin from 'vite-plugin-svgr';
import { ONTIME_VERSION } from './src/ONTIME_VERSION';
@@ -48,6 +49,11 @@ export default defineConfig({
algorithm: 'brotliCompress',
exclude: /\.(html)$/, // Ontime cloud: Exclude HTML files from compression so we can change the base property at runtime
}),
prismjsPlugin({
manual: true,
languages: ['css',],
css: true
}),
],
server: {
port: 3000,
@@ -88,16 +94,6 @@ export default defineConfig({
build: {
outDir: './build',
sourcemap: true,
rollupOptions: {
output: {
manualChunks(id) {
// Split vendor code
if (id.includes('node_modules')) {
return 'vendor';
}
},
},
},
},
resolve: {
alias: {
+1 -1
View File
@@ -1,3 +1,3 @@
export interface IAdapter {
shutdown: () => void;
shutdown: () => Promise<void>;
}
+9 -2
View File
@@ -72,10 +72,17 @@ class OscServer implements IAdapter {
});
this.udpSocket.bind(port);
}
shutdown() {
shutdown(): Promise<void> {
logger.info(LogOrigin.Rx, 'OSC: Closing server');
this.udpSocket?.close();
const socket = this.udpSocket;
this.udpSocket = null;
if (!socket) {
return Promise.resolve();
}
return new Promise((resolve) => {
socket.close(() => resolve());
});
}
}
+20 -8
View File
@@ -223,14 +223,9 @@ class SocketServer implements IAdapter {
// message is any serializable value
public sendAsJson<T extends MessageTag>(tag: T, payload: Pick<WsPacketToClient & { tag: T }, 'payload'>['payload']) {
const wss = this.wss;
if (!wss || wss.clients.size === 0) {
return;
}
try {
const stringifiedMessage = JSON.stringify({ tag, payload });
wss.clients.forEach((client) => {
this.wss?.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(stringifiedMessage);
}
@@ -240,8 +235,25 @@ class SocketServer implements IAdapter {
}
}
shutdown() {
this.wss?.close();
shutdown(): Promise<void> {
const wss = this.wss;
if (!wss) {
return Promise.resolve();
}
return new Promise((resolve) => {
// Notify clients first so they can reconnect gracefully
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) {
client.close(1001, 'Server shutting down');
}
}
wss.close(() => {
this.wss = null;
resolve();
});
});
}
}
@@ -24,6 +24,7 @@ router.post('/css', validatePostCss, async (req: Request, res: Response<never |
const { css } = req.body;
try {
await writeCssFile(css);
sendRefetch(RefetchKey.CssOverride);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
@@ -34,6 +35,7 @@ router.post('/css', validatePostCss, async (req: Request, res: Response<never |
router.post('/css/restore', async (_req: Request, res: Response<string | ErrorResponse>) => {
try {
await writeCssFile(defaultCss);
sendRefetch(RefetchKey.CssOverride);
res.status(200).send(defaultCss);
} catch (error) {
const message = getErrorMessage(error);
@@ -0,0 +1,37 @@
import xlsx from 'xlsx';
import { demoDb } from '../../../models/demoProject.js';
import { generateExcelFile } from '../excel.service.js';
describe('generateExcelFile()', () => {
it('sanitises long worksheet names to an Excel-compatible value', () => {
const buffer = generateExcelFile(
{
...demoDb.rundowns.default,
title: 'This is a very long name with many characters and weird things: like [Main]/?*',
},
demoDb.customFields,
);
const workbook = xlsx.read(buffer, { type: 'buffer' });
const worksheetName = workbook.SheetNames[0];
expect(worksheetName).toBeDefined();
expect(worksheetName.length).toBeLessThanOrEqual(31);
expect(worksheetName).not.toMatch(/[:\\/?*3[\]]/);
});
it('falls back to default worksheet name when title is fully invalid', () => {
const buffer = generateExcelFile(
{
...demoDb.rundowns.default,
title: '[]:*?/\\',
},
demoDb.customFields,
);
const workbook = xlsx.read(buffer, { type: 'buffer' });
expect(workbook.SheetNames[0]).toBe('Rundown');
});
});
@@ -21,6 +21,18 @@ import { rundownToTabular } from './excel.utils.js';
// we keep the excel data in memory to allow the flow upload -> preview
let excelData: WorkBook = xlsx.utils.book_new();
const maxWorksheetNameLength = 31;
const invalidWorksheetCharsRegex = /[:\\/?*[\]]/g;
function getValidWorksheetName(title: string): string {
const sanitisedTitle = title.replaceAll(invalidWorksheetCharsRegex, ' ').trim().replace(/\s+/g, ' ');
const withFallback = sanitisedTitle.length > 0 ? sanitisedTitle : 'Rundown';
const truncatedTitle = withFallback.slice(0, maxWorksheetNameLength).trim();
return truncatedTitle.length > 0 ? truncatedTitle : 'Rundown';
}
/**
* Receives and parses an excel file
* The file is deleted after being read
@@ -93,7 +105,8 @@ export function generateExcelFile(rundown: Rundown, customFields: CustomFields):
const workbook = xlsx.utils.book_new();
const worksheet = xlsx.utils.aoa_to_sheet(rundownToTabular(rundown, customFields));
xlsx.utils.book_append_sheet(workbook, worksheet, rundown.title || 'Rundown');
const worksheetName = getValidWorksheetName(rundown.title || 'Rundown');
xlsx.utils.book_append_sheet(workbook, worksheet, worksheetName);
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
}
@@ -186,8 +186,7 @@ function remove(rundown: Rundown, entry: OntimeEntry) {
// for ontime groups, we need to iterate through the children and delete them
for (let i = 0; i < entry.entries.length; i++) {
const nestedEntryId = entry.entries[i];
// nested entries are not part of the top-level order
deleteEntry(nestedEntryId, false);
deleteEntry(nestedEntryId);
}
} else if (entry.parent) {
// at this point, we are handling entries inside a group, so we need to remove the reference
@@ -206,13 +205,10 @@ function remove(rundown: Rundown, entry: OntimeEntry) {
edit(rundown, { id: parentGroup.id, entries: filteredEvents });
}
}
deleteEntry(entry.id);
function deleteEntry(idToDelete: EntryId, shouldDeleteFromOrder: boolean = true) {
if (shouldDeleteFromOrder) {
rundown.order = deleteById(rundown.order, idToDelete);
}
function deleteEntry(idToDelete: EntryId) {
rundown.order = deleteById(rundown.order, idToDelete);
delete rundown.entries[idToDelete];
}
}
@@ -18,6 +18,7 @@ import {
groupEntries,
initRundown,
loadRundown,
pasteEntries,
reorderEntry,
swapEvents,
ungroupEntries,
@@ -30,6 +31,7 @@ import {
entryPutValidator,
entryReorderValidator,
entrySwapValidator,
pastePostValidator,
rundownArrayOfIds,
rundownPostValidator,
validateRundownMutation,
@@ -302,6 +304,29 @@ router.post(
},
);
/**
* Pastes entries from a source rundown into the active rundown
*/
router.post(
'/:rundownId/paste',
pastePostValidator,
validateRundownMutation,
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = await pasteEntries({
entryIds: req.body.entryIds,
sourceRundownId: req.body.sourceRundownId,
afterId: req.body.afterId,
beforeId: req.body.beforeId,
});
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
/**
* Creates a group out of a list of entries
*/
@@ -140,6 +140,7 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
let batchDidInvalidate = false;
const changedIds: EntryId[] = [];
const patchedEntries: OntimeEntry[] = [];
for (let i = 0; i < ids.length; i++) {
const currentId = ids[i];
const currentEntry = rundown.entries[currentId];
@@ -164,20 +165,15 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
continue;
}
const { didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId });
const { entry, didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId });
changedIds.push(currentId);
patchedEntries.push(entry);
if (didInvalidate) {
batchDidInvalidate = true;
}
}
// skip commit and notifications when the patch is no-op.
if (changedIds.length === 0) {
return getCurrentRundown() as Rundown;
}
const { rundown: rundownResult, rundownMetadata, revision } = commit(batchDidInvalidate);
// schedule the side effects
@@ -196,14 +192,7 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
* Deletes a known entry from the current rundown
*/
export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
const currentRundown = getCurrentRundown();
const shouldDelete = entryIds.some((entryId) => Object.hasOwn(currentRundown.entries, entryId));
if (!shouldDelete) {
return currentRundown as Rundown;
}
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
const deletedIds: EntryId[] = [];
for (let i = 0; i < entryIds.length; i++) {
const entry = rundown.entries[entryIds[i]];
@@ -211,11 +200,6 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
continue;
}
rundownMutation.remove(rundown, entry);
deletedIds.push(entry.id);
}
if (deletedIds.length === 0) {
return currentRundown as Rundown;
}
const { rundown: rundownResult, rundownMetadata, revision } = commit();
@@ -226,7 +210,7 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundownMetadata, revision, { timer: deletedIds, external: true });
notifyChanges(rundownMetadata, revision, { timer: entryIds, external: true });
});
return rundownResult;
@@ -350,6 +334,102 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
return rundownResult;
}
/**
* Pastes a list of entries into the active rundown
* Supports cross-rundown paste within the same instance
* @throws if any entry is not found in the source rundown
*/
export async function pasteEntries(payload: {
entryIds: EntryId[];
sourceRundownId: string;
afterId?: EntryId;
beforeId?: EntryId;
}): Promise<Rundown> {
const { entryIds, sourceRundownId, afterId, beforeId } = payload;
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
// resolve source rundown
const sourceRundown =
sourceRundownId === rundown.id ? rundown : getDataProvider().getRundown(sourceRundownId);
// validate all entry IDs exist in source
for (const entryId of entryIds) {
if (!sourceRundown.entries[entryId]) {
throw new Error(`Entry with ID ${entryId} not found in source rundown`);
}
}
// resolve insertion position
let currentAfterId: EntryId | undefined;
if (afterId) {
currentAfterId = afterId;
} else if (beforeId) {
// for "before" positioning, pass it to the first clone and then chain after
currentAfterId = undefined;
}
const newEntryIds: EntryId[] = [];
// sort entry IDs by source rundown flatOrder so paste preserves rundown order
// regardless of the order they were selected (e.g. ctrl-click order)
const validIds = entryIds.filter((id) => sourceRundown.entries[id]);
const orderedIds = validIds.sort((a, b) => {
const idxA = sourceRundown.flatOrder.indexOf(a);
const idxB = sourceRundown.flatOrder.indexOf(b);
return idxA - idxB;
});
for (let i = 0; i < orderedIds.length; i++) {
const entryId = orderedIds[i];
const sourceEntry = sourceRundown.entries[entryId];
// resolve position reference for this entry
// when pasting a group and the reference is a child inside another group,
// normalise to the parent group so the pasted group lands at the top level
const options: InsertOptions = {};
if (i === 0 && beforeId && !afterId) {
options.before = normaliseGroupPosition(rundown, sourceEntry, beforeId);
} else if (currentAfterId) {
options.after = normaliseGroupPosition(rundown, sourceEntry, currentAfterId);
}
const newEntry = rundownMutation.clone(rundown, sourceEntry, options);
newEntryIds.push(newEntry.id);
// chain: each new entry becomes the afterId for the next
currentAfterId = newEntry.id;
}
const { rundown: rundownResult, rundownMetadata, revision } = commit();
// schedule the side effects
setImmediate(() => {
updateRuntimeOnChange(rundownMetadata);
notifyChanges(rundownMetadata, revision, { timer: newEntryIds, external: true });
});
return rundownResult;
}
/**
* When pasting a group, the position reference must be a top-level entry.
* If the reference is a child inside another group, resolve to the parent
* so the pasted group ends up at the correct position in rundown.order.
*/
function normaliseGroupPosition(rundown: Rundown, sourceEntry: OntimeEntry, referenceId: EntryId): EntryId {
if (!isOntimeGroup(sourceEntry)) {
return referenceId;
}
const referenceEntry = rundown.entries[referenceId];
if (referenceEntry && 'parent' in referenceEntry && referenceEntry.parent) {
return referenceEntry.parent;
}
return referenceId;
}
/**
* Clones an entry, ensuring that all dependencies are preserved
* Handles cloning children if the entry is a group
@@ -587,10 +667,8 @@ type NotifyChangesOptions = {
* Notify services of changes in the rundown
*/
function notifyChanges(rundownMetadata: RundownMetadata, revision: number, options: NotifyChangesOptions) {
const shouldNotifyTimer = options.timer === true || (Array.isArray(options.timer) && options.timer.length > 0);
// notify timer service of changed events
if (shouldNotifyTimer) {
if (options.timer) {
runtimeService.notifyOfChangedEvents(rundownMetadata);
}
@@ -83,4 +83,14 @@ export const rundownArrayOfIds = [
requestValidationFunction,
];
export const pastePostValidator = [
body('entryIds').isArray().notEmpty(),
body('entryIds.*').isString(),
body('sourceRundownId').isString().notEmpty(),
body('afterId').optional().isString(),
body('beforeId').optional().isString(),
requestValidationFunction,
];
// #endregion operations on rundown entries =======================
+91 -25
View File
@@ -42,6 +42,7 @@ import { consoleError, consoleHighlight, consoleSuccess } from './utils/console.
import { generateCrashReport } from './utils/generateCrashReport.js';
import { getNetworkInterfaces } from './utils/network.js';
import { clearUploadfolder } from './utils/upload.js';
import { withTimeout } from './utils/withTimeout.js';
console.log('\n');
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -64,6 +65,7 @@ const prefix = updateRouterPrefix();
// Create express APP
const app = express();
let isShuttingDown = false;
if (!isProduction) {
// log server timings to requests
app.use(serverTiming());
@@ -85,6 +87,15 @@ app.get(`${prefix}/health`, (_req, res) => {
res.status(200).send('OK');
});
// readiness probe route for orchestrators (e.g. kubernetes)
app.get(`${prefix}/ready`, (_req, res) => {
if (isShuttingDown) {
res.status(503).send('SHUTTING_DOWN');
return;
}
res.status(200).send('READY');
});
// Implement route endpoints
app.use(`${prefix}/login`, loginRouter); // router for login flow
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
@@ -136,6 +147,7 @@ enum OntimeStartOrder {
let step = OntimeStartOrder.InitAssets;
let expressServer: Server | null = null;
let shutdownPromise: Promise<void> | null = null;
const checkStart = (currentState: OntimeStartOrder) => {
if (step !== currentState) {
@@ -250,34 +262,88 @@ export const startIntegrations = async () => {
};
/**
* @description clean shutdown app services
* @param {number} exitCode
* @return {Promise<void>}
* Clean shutdown app services
* - it avoid concurrency issues with deduplication of request to shutdown
* - extracts exit code to modify cleanup behaviour
*/
export const shutdown = async (exitCode = 0) => {
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
await flushPendingWrites().catch((_error) => {
/** nothing do to here */
});
// clear the restore file if it was a normal exit
// 0 means it was a SIGNAL
// 1 means crash -> keep the file
// 2 means dev crash -> do nothing
// 3 means container shutdown -> keep the file
// 99 means there was a shutdown request from the UI
if (exitCode === 0 || exitCode === 99) {
await restoreService.clear();
await portManager.shutdown();
export async function shutdown(exitCode = 0): Promise<void> {
if (shutdownPromise) {
return shutdownPromise;
}
expressServer?.close();
runtimeService.shutdown();
logger.shutdown();
oscServer.shutdown();
socket.shutdown();
process.exit(exitCode);
shutdownPromise = performShutdown(exitCode);
return shutdownPromise;
};
const closeHttpServer = async (server: Server | null): Promise<void> => {
if (!server) return;
const closePromise = new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
if ((error as NodeJS.ErrnoException).code === 'ERR_SERVER_NOT_RUNNING') {
resolve();
return;
}
reject(error);
return;
}
resolve();
});
});
server.closeIdleConnections();
server.closeAllConnections();
await closePromise;
};
const shutdownGlobalTimeout = 10_000; // 10 seconds
const shutdownTimeout = 4_000; // 4 seconds
async function performShutdown(exitCode: number): Promise<void> {
isShuttingDown = true;
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
// if shutdown takes longer than 10 seconds, force exit to avoid hanging processes
const forceExitTimer = setTimeout(() => {
consoleError('Forced shutdown after timeout');
process.exit(exitCode);
}, shutdownGlobalTimeout);
try {
runtimeService.shutdown();
// Block for at most 4 seconds on each shutdown segment
await withTimeout(
flushPendingWrites().catch((_error) => {
/** nothing do to here */
}),
shutdownTimeout,
);
// clear the restore file if it was a normal exit
// 0 means it was a SIGNAL
// 1 means crash -> keep the file
// 2 means dev crash -> do nothing
// 3 means container shutdown -> keep the file
// 99 means there was a shutdown request from the UI
if (exitCode === 0 || exitCode === 99) {
await withTimeout(restoreService.clear(), shutdownTimeout);
await withTimeout(portManager.shutdown(), shutdownTimeout);
}
await withTimeout(
Promise.all([closeHttpServer(expressServer), socket.shutdown(), oscServer.shutdown()]),
shutdownTimeout,
);
} catch (error) {
logger.error(LogOrigin.Server, `Shutdown error: ${error}`, false);
} finally {
clearTimeout(forceExitTimer);
logger.shutdown();
process.exit(exitCode);
}
};
process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`));
+15
View File
@@ -0,0 +1,15 @@
/**
* Resolves or rejects with the provided promise, but fails if it does not settle within `timeoutMs`.
*/
export const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T> => {
let timer: NodeJS.Timeout | null = null;
const timeout = new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error('Operation timed out')), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer) {
clearTimeout(timer);
}
});
};
@@ -31,7 +31,7 @@ test('Copy-paste', async ({ page }) => {
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('4');
});
test('Cut-paste', async ({ page }) => {
test('Multi-event copy-paste', async ({ page }) => {
await page.goto('http://localhost:4001/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
@@ -40,27 +40,39 @@ test('Cut-paste', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
// create events
// create three events with distinct titles
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByTestId('entry-1').getByTestId('entry__title').click();
await page.getByTestId('entry-1').getByTestId('entry__title').fill('first');
await page.getByTestId('entry-1').getByTestId('entry__title').fill('alpha');
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
await page.getByRole('button', { name: 'Event' }).nth(4).click();
await page.getByTestId('entry-2').getByTestId('entry__title').click();
await page.getByTestId('entry-2').getByTestId('entry__title').fill('second');
await page.getByTestId('entry-2').getByTestId('entry__title').fill('beta');
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
// cut first event, paste below second
await page.getByTestId('entry-1').getByTestId('rundown-event').getByText('1').click();
await page.getByTestId('entry-1').getByTestId('rundown-event').filter({ hasText: '1' }).press('ControlOrMeta+x');
await page.getByTestId('entry-2').getByTestId('rundown-event').getByText('2').click();
await page.getByTestId('entry-2').getByTestId('rundown-event').filter({ hasText: '2' }).press('ControlOrMeta+v');
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await page.getByTestId('entry-3').getByTestId('entry__title').click();
await page.getByTestId('entry-3').getByTestId('entry__title').fill('gamma');
await page.getByTestId('entry-3').getByTestId('entry__title').press('Enter');
// we can verify that the entries have swapped places
const events = await page.getByTestId('entry__title').all();
await expect(events[0]).toHaveValue('second');
await expect(events[1]).toHaveValue('first');
// multi-select first and third events via ctrl-click
await page.getByTestId('entry-1').getByTestId('rundown-event').click();
await page.getByTestId('entry-3').getByTestId('rundown-event').click({ modifiers: ['Control'] });
// copy and paste after the last event
await page.keyboard.press('ControlOrMeta+c');
await page.getByTestId('entry-3').getByTestId('rundown-event').click();
await page.keyboard.press('ControlOrMeta+v');
// should now have 5 events: alpha, beta, gamma, alpha-copy, gamma-copy
await expect(page.getByTestId('rundown-event')).toHaveCount(5);
const titles = await page.getByTestId('entry__title').all();
await expect(titles[0]).toHaveValue('alpha');
await expect(titles[1]).toHaveValue('beta');
await expect(titles[2]).toHaveValue('gamma');
await expect(titles[3]).toHaveValue('alpha');
await expect(titles[4]).toHaveValue('gamma');
});
test('Move', async ({ page }) => {
@@ -6,6 +6,7 @@ export enum RefetchKey {
Rundown = 'rundown',
UrlPresets = 'url-presets',
ViewSettings = 'view-settings',
CssOverride = 'css-override',
Translation = 'translation',
Settings = 'settings',
}
+1029 -818
View File
File diff suppressed because it is too large Load Diff