mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 07:29:08 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8c90a966a | |||
| 703dee35a4 | |||
| 2a7f5b7872 | |||
| c6eccec30e | |||
| 5220c2c374 | |||
| 4eeeb294f7 | |||
| a006331fea | |||
| ac0ef06459 | |||
| 4d04fe35c3 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.11.0",
|
||||
"version": "4.12.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.11.0",
|
||||
"version": "4.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.6.0",
|
||||
"@base-ui/react": "1.7.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -13,12 +13,12 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@mantine/hooks": "^8.3.7",
|
||||
"@mantine/hooks": "^9.5.1",
|
||||
"@sentry/react": "^10.59.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-table": "^9.1.2",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.10",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.18.0",
|
||||
@@ -29,7 +29,7 @@
|
||||
"react-dom": "^19.2.7",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-hook-form": "^7.80.0",
|
||||
"react-icons": "5.6.0",
|
||||
"react-icons": "5.7.0",
|
||||
"react-router": "^8.0.1",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"zustand": "^5.0.14"
|
||||
@@ -60,7 +60,8 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "5.1.1",
|
||||
"@sentry/vite-plugin": "5.4.0",
|
||||
"@types/node": "catalog:",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
@@ -72,8 +73,8 @@
|
||||
"ontime-utils": "workspace:*",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "catalog:",
|
||||
"vite": "8.0.1",
|
||||
"vite-plugin-compression2": "2.5.1",
|
||||
"vite": "8.2.1",
|
||||
"vite-plugin-compression2": "2.5.3",
|
||||
"vite-plugin-svgr": "4.5.0",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
|
||||
@@ -176,6 +176,13 @@ export async function postCloneEntry(
|
||||
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request events duration to fit inside the group target
|
||||
*/
|
||||
export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for grouping a list of entries into a group
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dialog } from '@base-ui/react/dialog';
|
||||
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
||||
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
|
||||
import { memo } from 'react';
|
||||
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { LuCoffee } from 'react-icons/lu';
|
||||
@@ -33,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
const isSmallScreen = useIsSmallScreen();
|
||||
|
||||
const [isRenameOpen, handlers] = useDisclosure(false);
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { fullscreen, toggle } = useFullscreenDocument();
|
||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
requestEventSwap,
|
||||
requestGroupEntries,
|
||||
requestUngroup,
|
||||
requestFitGroupTarget,
|
||||
} from '../api/rundown';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { useEditorSettings } from '../stores/editorSettings';
|
||||
@@ -466,7 +467,27 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
return previousEnd;
|
||||
}
|
||||
},
|
||||
[getCurrentRundownData, updateEntryMutation, queryClient],
|
||||
[getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey],
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates time of existing event so it satisfies the group target duration
|
||||
* @param eventId {EntryId} - id of the event
|
||||
*/
|
||||
const matchGroupDuration = useCallback(
|
||||
async (eventId: EntryId) => {
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
try {
|
||||
await requestFitGroupTarget(rundownId, eventId);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
},
|
||||
[getCurrentRundownData],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -1009,6 +1030,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
matchGroupDuration,
|
||||
}),
|
||||
[
|
||||
addEntry,
|
||||
@@ -1026,6 +1048,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
matchGroupDuration,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
-35
@@ -1,5 +1,3 @@
|
||||
import { AppMode } from '../ontimeConfig';
|
||||
|
||||
declare module '*.scss' {
|
||||
const content: Record<string, string>;
|
||||
export default content;
|
||||
@@ -32,39 +30,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare custom data we pass to the table
|
||||
* - `handleUpdate` callback to update the entry when the user edits a cell
|
||||
* - `handleUpdateTimer` callback to update the timer for a specific event
|
||||
* - `options-showDelayedTimes` whether to show or hide delayed times
|
||||
* - `options-hideTableSeconds` whether to hide seconds in the table
|
||||
* - `options-hideIndexColumn` whether to hide the index column
|
||||
* - `options-cuesheetMode` run or edit mode
|
||||
*
|
||||
* And metadata specific for each column
|
||||
* - `canWrite` whether the user can write to this column
|
||||
* - `colour` background colour associated with a custom field
|
||||
*/
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface TableMeta<TData extends RowData> {
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
|
||||
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
|
||||
options: {
|
||||
showDelayedTimes: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
cuesheetMode: AppMode;
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
canWrite: boolean;
|
||||
colour?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow passing CSS Properties
|
||||
*/
|
||||
|
||||
@@ -200,8 +200,7 @@ $card-padding: 2rem;
|
||||
.overlay {
|
||||
position: absolute;
|
||||
z-index: $zindex-backdrop;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
backdrop-filter: blur(2px);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.updateIndicator {
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 99px;
|
||||
background-color: $red-400;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './AppVersion.module.scss';
|
||||
|
||||
export default function AppVersion() {
|
||||
const { data, isError } = useAppVersion();
|
||||
|
||||
@@ -18,7 +20,12 @@ export default function AppVersion() {
|
||||
return (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={`Ontime ${appVersion}`}
|
||||
title={
|
||||
<>
|
||||
<span className={style.updateIndicator} aria-hidden='true' />
|
||||
{`Ontime ${appVersion}`}
|
||||
</>
|
||||
}
|
||||
description={
|
||||
isOntimeCloud
|
||||
? `Version ${data.version} is available. Restart your stage to update.`
|
||||
@@ -26,7 +33,7 @@ export default function AppVersion() {
|
||||
}
|
||||
/>
|
||||
{!isOntimeCloud && (
|
||||
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
|
||||
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
|
||||
)}
|
||||
</Panel.ListItem>
|
||||
);
|
||||
|
||||
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{rootError && <Panel.Error>{rootError}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{data.pendingRestart && (
|
||||
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
|
||||
)}
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
||||
<div>
|
||||
<Editor.Label htmlFor='eventId'>Plan offset</Editor.Label>
|
||||
<TextLikeInput
|
||||
offset={planOffsetLabel}
|
||||
offset={planOffsetLabel === 'under' ? 'over' : planOffsetLabel}
|
||||
className={cx([style.textLikeInput, planOffset === null && style.inactive])}
|
||||
disabled
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Day, EndAction, EntryId, Playback, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { Day, EndAction, EntryId, Maybe, OntimeGroup, Playback, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { isPlaybackActive } from 'ontime-utils';
|
||||
import { MouseEvent, useEffect, useRef } from 'react';
|
||||
import {
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
IoTrash,
|
||||
IoUnlink,
|
||||
} from 'react-icons/io5';
|
||||
import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||
import { TbClockPin, TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useEntry } from '../../../common/hooks-query/useRundown';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
@@ -102,7 +103,10 @@ export default function RundownEvent({
|
||||
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
|
||||
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
|
||||
|
||||
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
|
||||
const parentGroup = useEntry(parent) as Maybe<OntimeGroup>;
|
||||
|
||||
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents, matchGroupDuration } =
|
||||
useEntryActionsContext();
|
||||
|
||||
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
|
||||
const unselect = useEventSelection((state) => state.unselect);
|
||||
@@ -114,6 +118,15 @@ export default function RundownEvent({
|
||||
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
const [enableMatchDuration, groupTargetDurationDescription] = (() => {
|
||||
if (!parentGroup || parentGroup.targetDuration === null || parentGroup.duration === parentGroup.targetDuration)
|
||||
return [false, ''];
|
||||
const { targetDuration, duration } = parentGroup;
|
||||
return targetDuration > duration
|
||||
? [true, 'Increase event duration to fit the group target']
|
||||
: [true, 'Decrease event duration to fit the group target'];
|
||||
})();
|
||||
|
||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() =>
|
||||
selectedEvents.size > 1
|
||||
? [
|
||||
@@ -172,6 +185,17 @@ export default function RundownEvent({
|
||||
updateEntry({ id: eventId, flag: !flag });
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Match Group Target Duration',
|
||||
description: groupTargetDurationDescription,
|
||||
icon: TbClockPin,
|
||||
onClick: () => {
|
||||
if (!parent) return;
|
||||
matchGroupDuration(eventId);
|
||||
},
|
||||
disabled: !enableMatchDuration,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
|
||||
@@ -74,42 +74,36 @@
|
||||
.metaLabel {
|
||||
color: $muted-gray;
|
||||
font-size: calc(1rem - 3px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.strike {
|
||||
text-decoration: wavy underline;
|
||||
margin-right: 0.25rem;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.duration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: $ui-white;
|
||||
|
||||
&.warning {
|
||||
.strike {
|
||||
// color: $playback-over;
|
||||
text-decoration: wavy underline;
|
||||
text-decoration-color: $playback-over;
|
||||
}
|
||||
.offsetLabel {
|
||||
background-color: $playback-over;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
opacity: 0.6;
|
||||
color: $muted-gray;
|
||||
}
|
||||
|
||||
.over {
|
||||
color: $playback-over;
|
||||
.strike {
|
||||
text-decoration-color: $playback-over;
|
||||
}
|
||||
.offsetLabel {
|
||||
background-color: $playback-over;
|
||||
}
|
||||
}
|
||||
.under {
|
||||
color: $playback-under;
|
||||
.strike {
|
||||
text-decoration-color: $playback-under;
|
||||
}
|
||||
.offsetLabel {
|
||||
background-color: $playback-under;
|
||||
}
|
||||
.target {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.drag {
|
||||
|
||||
@@ -2,24 +2,25 @@ import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EntryId, OntimeGroup } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
import { MouseEvent, useRef } from 'react';
|
||||
import { MouseEvent, useCallback, useRef } from 'react';
|
||||
import {
|
||||
IoChevronDown,
|
||||
IoChevronUp,
|
||||
IoDuplicateOutline,
|
||||
IoFolderOpenOutline,
|
||||
IoLockClosed,
|
||||
IoReorderTwo,
|
||||
IoTrash,
|
||||
IoLockClosed,
|
||||
} from 'react-icons/io5';
|
||||
import { TbClockPin } from 'react-icons/tb';
|
||||
|
||||
import IconButton from '../../../common/components/buttons/IconButton';
|
||||
import Tag from '../../../common/components/tag/Tag';
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { getOffsetState } from '../../../common/utils/offset';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||
import TitleEditor from '../common/TitleEditor';
|
||||
@@ -40,12 +41,31 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
'use memo';
|
||||
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
const { clone, ungroup, deleteEntry } = useEntryActionsContext();
|
||||
const { clone, ungroup, deleteEntry, updateEntry } = useEntryActionsContext();
|
||||
|
||||
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
||||
|
||||
const isDurationMatching = data.targetDuration !== null && data.targetDuration === data.duration;
|
||||
|
||||
const [planOffset, offset] = (() => {
|
||||
if (data.targetDuration === null) {
|
||||
return [null, 0];
|
||||
}
|
||||
|
||||
const offset = data.duration - data.targetDuration;
|
||||
if (offset === 0) {
|
||||
return [null, 0];
|
||||
}
|
||||
const absOffset = Math.abs(offset);
|
||||
return [`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`, offset];
|
||||
})();
|
||||
|
||||
const matchDuration = useCallback(() => {
|
||||
updateEntry({ id: data.id, targetDuration: data.duration });
|
||||
}, [data.duration, data.id, updateEntry]);
|
||||
|
||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
||||
{
|
||||
type: 'item',
|
||||
@@ -62,6 +82,18 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
disabled: data.entries.length === 0,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Match Content Duration',
|
||||
icon: TbClockPin,
|
||||
onClick: matchDuration,
|
||||
disabled: isDurationMatching,
|
||||
description:
|
||||
offset > 0
|
||||
? "Increase group target duration to match it's contents"
|
||||
: "Decrease group target duration to match it's contents",
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Delete Group',
|
||||
@@ -105,22 +137,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
const binderColours = data.colour && getAccessibleColour(data.colour);
|
||||
const isValidDrop = isDragging && over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
|
||||
|
||||
const [planOffset, planOffsetLabel] = (() => {
|
||||
if (data.targetDuration === null) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const offset = data.duration - data.targetDuration;
|
||||
if (offset === 0) {
|
||||
return [null, 'under'];
|
||||
}
|
||||
const absOffset = Math.abs(offset);
|
||||
return [
|
||||
`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`,
|
||||
getOffsetState(offset),
|
||||
];
|
||||
})();
|
||||
|
||||
const dragStyle = {
|
||||
zIndex: isDragging ? 2 : 'inherit',
|
||||
transform: CSS.Translate.toString(transform),
|
||||
@@ -175,20 +191,18 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
<div className={style.metaLabel}>End</div>
|
||||
<div>{formatTime(data.timeEnd)}</div>
|
||||
</div>
|
||||
<div className={style.metaEntry}>
|
||||
<div className={style.metaLabel}>Duration</div>
|
||||
<div className={style.duration}>
|
||||
{planOffset === null ? (
|
||||
formatDuration(data.duration)
|
||||
) : (
|
||||
<span className={cx([planOffsetLabel && style[planOffsetLabel]])}>
|
||||
<span className={style.strike}>{formatDuration(data.duration)}</span>
|
||||
<Tag className={style.offsetLabel}>{planOffset}</Tag>
|
||||
</span>
|
||||
)}
|
||||
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
||||
<Tooltip text={'Group has target duration'} disabled={data.targetDuration === null}>
|
||||
<div className={style.metaEntry}>
|
||||
<div className={style.metaLabel}>
|
||||
Duration
|
||||
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
||||
</div>
|
||||
<div className={cx([style.duration, planOffset && style.warning])}>
|
||||
<span className={style.strike}>{formatDuration(data.duration)}</span>
|
||||
{planOffset && <Tag className={style.offsetLabel}>{planOffset}</Tag>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { CustomFields } from 'ontime-types';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { makeCuesheetColumns } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||
import type { CuesheetColumnDef } from '../../../views/cuesheet/cuesheet-table/cuesheetTable.features';
|
||||
|
||||
/**
|
||||
* Creates column definitions for the rundown table
|
||||
* Reuses cuesheetColsFactory with preset=undefined for full access
|
||||
*/
|
||||
export function makeRundownColumns(customFields: CustomFields): ColumnDef<ExtendedEntry>[] {
|
||||
export function makeRundownColumns(customFields: CustomFields): CuesheetColumnDef[] {
|
||||
// When preset=undefined, factory defaults to fullRead=true, fullWrite=true
|
||||
// canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run)
|
||||
return makeCuesheetColumns(customFields, AppMode.Edit, undefined);
|
||||
|
||||
@@ -7,14 +7,13 @@ import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import type { CuesheetColumnDef } from '../cuesheet-table/cuesheetTable.features';
|
||||
import { useColumnOrder } from '../cuesheet-table/useColumnManager';
|
||||
|
||||
interface CuesheetDndProps {
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
columns: CuesheetColumnDef[];
|
||||
tableRoot?: 'editor' | 'cuesheet';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTableNav } from '@table-nav/react';
|
||||
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { useTable } from '@tanstack/react-table';
|
||||
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
@@ -29,12 +29,17 @@ import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||
import TableMenu from './cuesheet-table-menu/TableMenu';
|
||||
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
|
||||
import {
|
||||
CuesheetColumnDef,
|
||||
CuesheetTable as CuesheetTableInstance,
|
||||
cuesheetTableFeatures,
|
||||
} from './cuesheetTable.features';
|
||||
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
||||
|
||||
import style from './CuesheetTable.module.scss';
|
||||
|
||||
type CuesheetTableBaseProps = {
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
columns: CuesheetColumnDef[];
|
||||
cuesheetMode: AppMode;
|
||||
source: RundownSource;
|
||||
insertElement?: ReactNode;
|
||||
@@ -120,7 +125,8 @@ export default function CuesheetTable({
|
||||
const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot);
|
||||
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
|
||||
|
||||
const table = useReactTable({
|
||||
const table = useTable({
|
||||
features: cuesheetTableFeatures,
|
||||
data: flatRundown,
|
||||
columns,
|
||||
columnResizeMode: 'onChange',
|
||||
@@ -131,7 +137,6 @@ export default function CuesheetTable({
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
meta,
|
||||
});
|
||||
|
||||
@@ -195,7 +200,7 @@ export default function CuesheetTable({
|
||||
return colSizes;
|
||||
// eslint-disable-next-line react-compiler/react-compiler -- unfortunately this is what we need
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
|
||||
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
|
||||
}, [table.state.columnResizing, table.state.columnSizing]);
|
||||
|
||||
const allLeafColumns = table.getAllLeafColumns();
|
||||
const { rows } = table.getRowModel();
|
||||
@@ -214,9 +219,7 @@ export default function CuesheetTable({
|
||||
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
|
||||
const fixedHeaderContent = useCallback(() => {
|
||||
return table.getHeaderGroups().map((headerGroup) => {
|
||||
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
|
||||
? CuesheetHeader
|
||||
: SortableCuesheetHeader;
|
||||
const HeaderComponent = table.state.columnResizing.isResizingColumn ? CuesheetHeader : SortableCuesheetHeader;
|
||||
|
||||
// if the table is being resized, we render non-sortable headers to avoid performance issues
|
||||
return (
|
||||
@@ -279,8 +282,8 @@ interface CuesheetVirtuosoContext {
|
||||
columnSizeVars: { [key: string]: number };
|
||||
cursor: string | null;
|
||||
listeners: ReturnType<typeof useTableNav>['listeners'];
|
||||
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
|
||||
table: Table<ExtendedEntry>;
|
||||
rows: ReturnType<CuesheetTableInstance['getRowModel']>['rows'];
|
||||
table: CuesheetTableInstance;
|
||||
handleAddNew?: (type: SupportedEntry) => void;
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -1,16 +1,16 @@
|
||||
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { HeaderGroup, flexRender } from '@tanstack/react-table';
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import type { CuesheetHeaderGroup } from '../cuesheetTable.features';
|
||||
import { Draggable, SortableCell, TableCell } from './SortableCell';
|
||||
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
|
||||
interface CuesheetHeaderProps {
|
||||
headerGroup: HeaderGroup<ExtendedEntry>;
|
||||
headerGroup: CuesheetHeaderGroup;
|
||||
cuesheetMode: AppMode;
|
||||
hideIndexColumn: boolean;
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function SortableCuesheetHeader({ headerGroup, cuesheetMode, hideIndexCol
|
||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||
draggable={<Draggable header={header} />}
|
||||
>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.isPlaceholder ? null : <FlexRender header={header} />}
|
||||
</SortableCell>
|
||||
);
|
||||
})}
|
||||
@@ -85,7 +85,7 @@ export function CuesheetHeader({ headerGroup, cuesheetMode, hideIndexColumn }: C
|
||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||
draggable={<Draggable header={header} />}
|
||||
>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.isPlaceholder ? null : <FlexRender header={header} />}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { EntryId, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
||||
|
||||
import style from './EventRow.module.scss';
|
||||
|
||||
@@ -25,7 +25,7 @@ interface EventRowProps {
|
||||
skip: boolean;
|
||||
parent: EntryId | null;
|
||||
rowIndex: number;
|
||||
table: Table<ExtendedEntry<OntimeEntry>>;
|
||||
table: CuesheetTable;
|
||||
injectedStyles?: CSSProperties;
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ function EventRow({
|
||||
data-testid={`cuesheet-cell-${cell.column.id}`}
|
||||
data-column-id={cell.column.id}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
<FlexRender cell={cell} />
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { CSSProperties, memo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
||||
|
||||
import style from './GroupRow.module.scss';
|
||||
|
||||
@@ -15,7 +15,7 @@ interface GroupRowProps {
|
||||
colour: string;
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
table: Table<ExtendedEntry>;
|
||||
table: CuesheetTable;
|
||||
injectedStyles?: CSSProperties;
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ function GroupRow({
|
||||
}}
|
||||
role='cell'
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
<FlexRender cell={cell} />
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
||||
|
||||
import style from './MilestoneRow.module.scss';
|
||||
|
||||
@@ -20,7 +20,7 @@ interface MilestoneRowProps {
|
||||
colour: string;
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
table: Table<ExtendedEntry>;
|
||||
table: CuesheetTable;
|
||||
injectedStyles?: CSSProperties;
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
@@ -102,7 +102,7 @@ function MilestoneRow({
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{canRender && <FlexRender cell={cell} />}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
+2
-3
@@ -1,9 +1,8 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Header } from '@tanstack/react-table';
|
||||
import { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import type { CuesheetHeaderCell } from '../cuesheetTable.features';
|
||||
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
|
||||
@@ -48,7 +47,7 @@ export function TableCell({ colSpan, injectedStyles, children, draggable }: Sort
|
||||
}
|
||||
|
||||
interface DraggableProps {
|
||||
header: Header<ExtendedEntry, unknown>;
|
||||
header: CuesheetHeaderCell;
|
||||
}
|
||||
|
||||
export function Draggable({ header }: DraggableProps) {
|
||||
|
||||
+12
-12
@@ -1,4 +1,3 @@
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { useCallback } from 'react';
|
||||
@@ -8,6 +7,7 @@ import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
||||
import type { CuesheetCellContext, CuesheetColumnDef } from '../cuesheetTable.features';
|
||||
import DurationInput from './DurationInput';
|
||||
import EditableImage from './EditableImage';
|
||||
import FlagCell from './FlagCell';
|
||||
@@ -17,11 +17,11 @@ import MutedText from './MutedText';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
|
||||
function getColumnLabel(column: CuesheetCellContext['column']): string {
|
||||
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
|
||||
}
|
||||
|
||||
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -60,7 +60,7 @@ function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry,
|
||||
);
|
||||
}
|
||||
|
||||
function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeEnd({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -100,7 +100,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, un
|
||||
);
|
||||
}
|
||||
|
||||
function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -131,7 +131,7 @@ function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntr
|
||||
);
|
||||
}
|
||||
|
||||
function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeMultiLineField({ row, column, table }: CuesheetCellContext) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
@@ -160,7 +160,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
|
||||
);
|
||||
}
|
||||
|
||||
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
function LazyImage({ row, column, table }: CuesheetCellContext) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||
@@ -178,7 +178,7 @@ function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>)
|
||||
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
|
||||
}
|
||||
|
||||
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
@@ -207,7 +207,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
|
||||
);
|
||||
}
|
||||
|
||||
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeFlagField({ row }: CuesheetCellContext) {
|
||||
const event = row.original;
|
||||
if (!isOntimeEvent(event) || !event.flag) {
|
||||
return null;
|
||||
@@ -215,7 +215,7 @@ function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
|
||||
return <FlagCell />;
|
||||
}
|
||||
|
||||
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeCustomField({ row, column, table }: CuesheetCellContext) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||
@@ -255,8 +255,8 @@ export function makeCuesheetColumns(
|
||||
customFields: CustomFields,
|
||||
cuesheetMode: AppMode,
|
||||
preset: URLPreset | undefined,
|
||||
): ColumnDef<ExtendedEntry>[] {
|
||||
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
|
||||
): CuesheetColumnDef[] {
|
||||
const columnsDef: CuesheetColumnDef[] = [];
|
||||
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
|
||||
|
||||
if (canRead('flag')) {
|
||||
|
||||
+4
-5
@@ -2,7 +2,6 @@ import { Popover } from '@base-ui/react/popover';
|
||||
import { Toggle } from '@base-ui/react/toggle';
|
||||
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
||||
import { Toolbar } from '@base-ui/react/toolbar';
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
import { ReactNode } from 'react';
|
||||
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
|
||||
|
||||
@@ -10,9 +9,9 @@ import Button from '../../../../common/components/buttons/Button';
|
||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||
import type { CuesheetColumn } from '../cuesheetTable.features';
|
||||
import CuesheetShareModal from './CuesheetShareModal';
|
||||
|
||||
import style from './CuesheetTableSettings.module.scss';
|
||||
@@ -37,7 +36,7 @@ type TableModeControls = {
|
||||
};
|
||||
|
||||
interface CuesheetTableHeaderToolbarProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
columns: CuesheetColumn[];
|
||||
optionsStore: TableHeaderOptionsStore;
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
@@ -111,7 +110,7 @@ interface ViewSettingsProps {
|
||||
}
|
||||
|
||||
interface ColumnSettingsProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
columns: CuesheetColumn[];
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
@@ -203,7 +202,7 @@ function ColumnSettings({
|
||||
|
||||
return (
|
||||
<Editor.Label key={`${column.id}-${visible}`} className={style.option}>
|
||||
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} />
|
||||
<Checkbox defaultChecked={visible} onCheckedChange={(checked) => column.toggleVisibility(checked)} />
|
||||
{columnHeader as ReactNode}
|
||||
</Editor.Label>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
columnOrderingFeature,
|
||||
columnResizingFeature,
|
||||
columnSizingFeature,
|
||||
columnVisibilityFeature,
|
||||
metaHelper,
|
||||
tableFeatures,
|
||||
} from '@tanstack/react-table';
|
||||
import type { CellContext, Column, ColumnDef, Header, HeaderGroup, Table } from '@tanstack/react-table';
|
||||
import type { TimeField } from 'ontime-types';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import type { AppMode } from '../../../ontimeConfig';
|
||||
|
||||
/**
|
||||
* Custom data we pass to the table
|
||||
* - `handleUpdate` callback to update the entry when the user edits a cell
|
||||
* - `handleUpdateTimer` callback to update the timer for a specific event
|
||||
* - `options-showDelayedTimes` whether to show or hide delayed times
|
||||
* - `options-hideTableSeconds` whether to hide seconds in the table
|
||||
* - `options-hideIndexColumn` whether to hide the index column
|
||||
* - `options-cuesheetMode` run or edit mode
|
||||
*/
|
||||
export interface CuesheetTableMeta {
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
|
||||
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
|
||||
options: {
|
||||
showDelayedTimes: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
cuesheetMode: AppMode;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata specific for each column
|
||||
* - `canWrite` whether the user can write to this column
|
||||
* - `colour` background colour associated with a custom field
|
||||
*/
|
||||
export interface CuesheetColumnMeta {
|
||||
canWrite: boolean;
|
||||
colour?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Features registered in the cuesheet and rundown tables.
|
||||
* In v9 an API only exists once its feature is registered, so this list is the
|
||||
* source of truth for what the table can do:
|
||||
* - `columnOrderingFeature`: user reorders columns by dragging the headers
|
||||
* - `columnVisibilityFeature`: user toggles columns in the table settings
|
||||
* - `columnSizingFeature`: column widths, exposed to CSS as custom properties
|
||||
* - `columnResizingFeature`: the drag handle in the header (requires sizing)
|
||||
*
|
||||
* The `tableMeta` / `columnMeta` slots replace the v8 global module augmentation:
|
||||
* they scope our meta types to this table instead of every table in the app.
|
||||
*/
|
||||
export const cuesheetTableFeatures = tableFeatures({
|
||||
columnOrderingFeature,
|
||||
columnVisibilityFeature,
|
||||
columnSizingFeature,
|
||||
columnResizingFeature,
|
||||
tableMeta: metaHelper<CuesheetTableMeta>(),
|
||||
columnMeta: metaHelper<CuesheetColumnMeta>(),
|
||||
});
|
||||
|
||||
export type CuesheetFeatures = typeof cuesheetTableFeatures;
|
||||
|
||||
/** Convenience aliases so consumers do not need to repeat the feature generic */
|
||||
export type CuesheetColumnDef = ColumnDef<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetTable = Table<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetCellContext = CellContext<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetHeaderGroup = HeaderGroup<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetHeaderCell = Header<CuesheetFeatures, ExtendedEntry, unknown>;
|
||||
export type CuesheetColumn = Column<CuesheetFeatures, ExtendedEntry, unknown>;
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { ColumnDef, ColumnSizingState, Updater } from '@tanstack/react-table';
|
||||
import { ColumnSizingState, Updater } from '@tanstack/react-table';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { debounce } from '../../../common/utils/debounce';
|
||||
import { makeStageKey } from '../../../common/utils/localStorage';
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import type { CuesheetColumnDef } from './cuesheetTable.features';
|
||||
|
||||
type TableRoot = 'editor' | 'cuesheet';
|
||||
|
||||
@@ -38,7 +38,7 @@ export function useColumnSizes(tableRoot: TableRoot = 'cuesheet') {
|
||||
};
|
||||
}
|
||||
|
||||
export function useColumnOrder(columns: ColumnDef<ExtendedEntry>[], tableRoot: TableRoot = 'cuesheet') {
|
||||
export function useColumnOrder(columns: CuesheetColumnDef[], tableRoot: TableRoot = 'cuesheet') {
|
||||
const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]);
|
||||
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.11.0",
|
||||
"version": "4.12.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -14,8 +14,8 @@
|
||||
"main": "src/main.js",
|
||||
"devDependencies": {
|
||||
"electron": "38.2.1",
|
||||
"electron-builder": "26.9.1",
|
||||
"wait-on": "^7.2.0"
|
||||
"electron-builder": "26.15.3",
|
||||
"wait-on": "^9.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
|
||||
|
||||
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
|
||||
submenu: [
|
||||
{
|
||||
label: 'New project...',
|
||||
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
|
||||
click: () => redirectWindow('/editor?settings=project__create'),
|
||||
},
|
||||
{
|
||||
label: 'Load...',
|
||||
@@ -202,6 +202,18 @@ function makeSettingsMenu(redirectWindow) {
|
||||
label: 'View settings',
|
||||
click: () => redirectWindow('/editor?settings=settings__view'),
|
||||
},
|
||||
{
|
||||
label: 'Custom views',
|
||||
click: () => redirectWindow('/editor?settings=settings__custom-views'),
|
||||
},
|
||||
{
|
||||
label: 'MCP Server',
|
||||
click: () => redirectWindow('/editor?settings=settings__mcp'),
|
||||
},
|
||||
{
|
||||
label: 'Server port',
|
||||
click: () => redirectWindow('/editor?settings=settings__port'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/resolver",
|
||||
"version": "4.11.0",
|
||||
"version": "4.12.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
"types": "./dist/main.d.ts",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "4.11.0",
|
||||
"version": "4.12.0",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
@@ -10,7 +10,7 @@
|
||||
"cookie": "1.0.2",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.6",
|
||||
"dotenv": "^16.0.1",
|
||||
"dotenv": "^17.0.0",
|
||||
"express": "5.2.1",
|
||||
"express-static-gzip": "3.0.1",
|
||||
"express-validator": "7.3.2",
|
||||
@@ -31,11 +31,11 @@
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "^8.5.10",
|
||||
"esbuild": "^0.24.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"server-timing": "^3.3.3",
|
||||
"ts-essentials": "catalog:",
|
||||
"tsx": "^4.19.2",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
TimerType,
|
||||
Trigger,
|
||||
} from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils';
|
||||
import { assertType } from 'vitest';
|
||||
|
||||
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
makeDeepClone,
|
||||
mergeRundownPreservingFields,
|
||||
isLoadedPlayable,
|
||||
eventDurationMatchGroupTarget,
|
||||
} from '../rundown.utils.js';
|
||||
|
||||
describe('test event validator', () => {
|
||||
@@ -610,3 +611,107 @@ describe('isLoadedPlayable()', () => {
|
||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventDurationMatchGroupTarget()', () => {
|
||||
it('returns unchanged duration when group already matches target', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('increases event duration when group is shorter than target', () => {
|
||||
// Group is 1h short of target, so event duration increases by 1h
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR * 2, // 2h
|
||||
groupDuration: MILLIS_PER_HOUR, // 1h
|
||||
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30); // 1h30m
|
||||
});
|
||||
|
||||
it('decreases event duration when group is longer than target', () => {
|
||||
// Group is 30m over target, so event duration decreases by 30m
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR, // 1h
|
||||
groupDuration: MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30, // 1h30m
|
||||
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
|
||||
});
|
||||
expect(result).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('handles zero target duration', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: 0,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_HOUR,
|
||||
});
|
||||
expect(result).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('handles zero group duration', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR,
|
||||
groupDuration: 0,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30);
|
||||
});
|
||||
|
||||
it('handles zero event duration', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR,
|
||||
groupDuration: MILLIS_PER_MINUTE * 30,
|
||||
eventDuration: 0,
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR - MILLIS_PER_MINUTE * 30);
|
||||
});
|
||||
|
||||
it('handles all zero values', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: 0,
|
||||
groupDuration: 0,
|
||||
eventDuration: 0,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('returns null when result would be negative', () => {
|
||||
// Group exceeds target by 1.5h, event shrinks by 1.5h (exceeds event duration)
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_MINUTE * 30,
|
||||
groupDuration: MILLIS_PER_HOUR * 2,
|
||||
eventDuration: MILLIS_PER_HOUR,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('handles large durations', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR * 24, // 24h
|
||||
groupDuration: MILLIS_PER_HOUR * 12, // 12h
|
||||
eventDuration: MILLIS_PER_HOUR, // 1h
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR * 13); // 13h
|
||||
});
|
||||
|
||||
it('returns null when targetDuration is null', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: null,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('returns null when duration would be over 24h', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: 30 * MILLIS_PER_HOUR,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
reorderEntry,
|
||||
swapEvents,
|
||||
ungroupEntries,
|
||||
entryFitGroupDuration,
|
||||
} from './rundown.service.js';
|
||||
import { normalisedToRundownArray } from './rundown.utils.js';
|
||||
import {
|
||||
@@ -337,6 +338,23 @@ router.post('/:rundownId/ungroup/:id', paramsWithId, async (req: Request, res: R
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Change a events duration to fit inside the group target
|
||||
*/
|
||||
router.post(
|
||||
'/:rundownId/:id/fit-group-duration',
|
||||
paramsWithId,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await entryFitGroupDuration(req.params.rundownId, req.params.id);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes a list of entries by their ID
|
||||
*/
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
hasChanges,
|
||||
mergeRundownPreservingFields,
|
||||
isLoadedPlayable,
|
||||
eventDurationMatchGroupTarget,
|
||||
} from './rundown.utils.js';
|
||||
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js';
|
||||
|
||||
@@ -447,6 +448,69 @@ export async function cloneEntry(rundownId: string, entryId: EntryId, options: I
|
||||
return rundownResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a events duration to fit inside the group target
|
||||
*/
|
||||
export async function entryFitGroupDuration(rundownId: string, entryId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
|
||||
const entry = rundown.entries[entryId];
|
||||
|
||||
if (!entry) {
|
||||
throw new Error('Entry not found');
|
||||
}
|
||||
|
||||
if (!isOntimeEvent(entry)) {
|
||||
throw new Error('Entry must be an event');
|
||||
}
|
||||
|
||||
const { parent } = entry;
|
||||
if (!parent) {
|
||||
throw new Error('Entry must be in a group');
|
||||
}
|
||||
|
||||
const group = rundown.entries[parent];
|
||||
|
||||
if (!group) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
|
||||
if (!isOntimeGroup(group)) {
|
||||
throw new Error('Group is not a group');
|
||||
}
|
||||
|
||||
const newDuration = eventDurationMatchGroupTarget({
|
||||
targetDuration: group.targetDuration,
|
||||
groupDuration: group.duration,
|
||||
eventDuration: entry.duration,
|
||||
});
|
||||
|
||||
if (newDuration === null) {
|
||||
throw new Error('Unable to fit a duration');
|
||||
}
|
||||
|
||||
const newEnd = entry.timeStart + newDuration;
|
||||
|
||||
rundownMutation.edit(rundown, {
|
||||
id: entryId,
|
||||
duration: newDuration,
|
||||
timeEnd: newEnd,
|
||||
timeStrategy: entry.timeStrategy,
|
||||
});
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// we need to notify the timer since we might be changing a running event
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { external: true, timer: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups a list of entries into a new group
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
EntryCustomFields,
|
||||
EntryId,
|
||||
ImportedFields,
|
||||
Maybe,
|
||||
OntimeBaseEvent,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
generateId,
|
||||
getCueCandidate,
|
||||
makeString,
|
||||
maxDuration,
|
||||
validateEndAction,
|
||||
validateTimerType,
|
||||
validateTimes,
|
||||
@@ -351,19 +353,19 @@ export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundo
|
||||
|
||||
/** List of event properties which do not need the rundown to be regenerated */
|
||||
enum RegenerateWhitelist {
|
||||
'id', // adding it for completeness, users cannot change ID
|
||||
'type', // adding it for completeness, users cannot change ID
|
||||
'cue',
|
||||
'title',
|
||||
'note',
|
||||
'endAction',
|
||||
'timerType',
|
||||
'countToEnd',
|
||||
'colour',
|
||||
'timeWarning',
|
||||
'timeDanger',
|
||||
'custom',
|
||||
'triggers',
|
||||
id, // adding it for completeness, users cannot change ID
|
||||
type, // adding it for completeness, users cannot change ID
|
||||
cue,
|
||||
title,
|
||||
note,
|
||||
endAction,
|
||||
timerType,
|
||||
countToEnd,
|
||||
colour,
|
||||
timeWarning,
|
||||
timeDanger,
|
||||
custom,
|
||||
triggers,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -601,3 +603,27 @@ export function getIntegerAndFraction(value: string): IncrementNumber {
|
||||
precision,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts an event's duration to fit inside the group target
|
||||
* @param targetDuration - The desired total duration for the group, or null
|
||||
* @param groupDuration - The current total duration of all events in the group
|
||||
* @param eventDuration - The current duration of the event being adjusted
|
||||
* @returns The adjusted event duration, or null if targetDuration is null or
|
||||
* the result would be negative
|
||||
*/
|
||||
export function eventDurationMatchGroupTarget({
|
||||
targetDuration,
|
||||
groupDuration,
|
||||
eventDuration,
|
||||
}: {
|
||||
targetDuration: Maybe<number>;
|
||||
groupDuration: number;
|
||||
eventDuration: number;
|
||||
}): Maybe<number> {
|
||||
if (targetDuration === null) return null;
|
||||
if (targetDuration === groupDuration) return null;
|
||||
const durationDiff = targetDuration - groupDuration;
|
||||
const newDuration = eventDuration + durationDiff;
|
||||
return newDuration < 0 || newDuration > maxDuration ? null : newDuration;
|
||||
}
|
||||
|
||||
@@ -20,12 +20,9 @@ export function createMcpServer(): Server {
|
||||
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(
|
||||
ListToolsRequestSchema,
|
||||
async (): Promise<ListToolsResult> => ({
|
||||
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
|
||||
}),
|
||||
);
|
||||
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({
|
||||
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
||||
const { name, arguments: args = {} } = request.params;
|
||||
|
||||
@@ -6,6 +6,25 @@ test('cuesheet displays events', async ({ page }) => {
|
||||
await expect(page.getByTestId('cuesheet-event').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('cuesheet persists column visibility', async ({ page }) => {
|
||||
await page.goto('/cuesheet');
|
||||
|
||||
const noteHeader = page.getByRole('columnheader', { name: 'Note' });
|
||||
const noteCell = page.getByTestId('cuesheet-event').first().getByTestId('cuesheet-cell-note');
|
||||
await expect(noteHeader).toBeVisible();
|
||||
await expect(noteCell).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Columns' }).click();
|
||||
await page.getByRole('checkbox', { name: 'Note' }).click();
|
||||
|
||||
await expect(noteHeader).toBeHidden();
|
||||
await expect(noteCell).toBeHidden();
|
||||
|
||||
await page.reload();
|
||||
await expect(noteHeader).toBeHidden();
|
||||
await expect(noteCell).toBeHidden();
|
||||
});
|
||||
|
||||
test('cuesheet datagrid does not submit timer cells on tab-out or escape', async ({ page }) => {
|
||||
await page.goto('/cuesheet');
|
||||
|
||||
|
||||
@@ -71,11 +71,15 @@ test('Move', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
|
||||
|
||||
// create events
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
|
||||
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
|
||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(3);
|
||||
|
||||
// copy move down
|
||||
await page.getByTestId('entry-1').getByTestId('rundown-event').getByText('1').click();
|
||||
@@ -86,15 +90,16 @@ test('Move', async ({ page }) => {
|
||||
.press('Alt+Control+ArrowDown');
|
||||
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('1');
|
||||
|
||||
// copy move up
|
||||
// move entry three up twice, waiting for each reorder before targeting its new row
|
||||
await page.getByTestId('entry-3').getByTestId('rundown-event').getByText('3').click();
|
||||
await page
|
||||
.getByTestId('entry-3')
|
||||
.getByTestId('rundown-event')
|
||||
.filter({ hasText: '3' })
|
||||
.press('Alt+ControlOrMeta+ArrowUp');
|
||||
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('3');
|
||||
await page
|
||||
.getByTestId('entry-3')
|
||||
.getByTestId('entry-2')
|
||||
.getByTestId('rundown-event')
|
||||
.filter({ hasText: '3' })
|
||||
.press('Alt+ControlOrMeta+ArrowUp');
|
||||
|
||||
@@ -13,23 +13,27 @@ test('time until absolute', async ({ context }) => {
|
||||
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
||||
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||
await editor.getByRole('button', { name: 'Delete all' }).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(0);
|
||||
|
||||
await editor.getByRole('button', { name: 'Create Event' }).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(1);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(2);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(3);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(4);
|
||||
|
||||
await editor.getByTestId('entry-1').getByTestId('rundown-event').click();
|
||||
const ids = new Array<string>();
|
||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
||||
const entry1Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||
await editor.getByTestId('entry-2').getByTestId('rundown-event').click();
|
||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
||||
const entry2Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||
await editor.getByTestId('entry-3').getByTestId('rundown-event').click();
|
||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
||||
const entry3Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||
await editor.getByTestId('entry-4').getByTestId('rundown-event').click();
|
||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
||||
const entry4Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||
|
||||
await countdown.goto(`/countdown?${ids.join('&sub=')}`);
|
||||
await countdown.goto(`/countdown?${entry1Id}&sub=${entry2Id}&sub=${entry3Id}&sub=${entry4Id}`);
|
||||
|
||||
// Create reusable locator references for different elements
|
||||
const entry2 = {
|
||||
@@ -57,8 +61,10 @@ test('time until absolute', async ({ context }) => {
|
||||
|
||||
await editor.getByRole('button', { name: 'Absolute' }).click();
|
||||
await editor.getByTestId('entry-1').getByLabel('Start event').click();
|
||||
await expect(editor.getByTestId('entry-1').getByLabel('Pause event')).toBeVisible();
|
||||
await expect(editor.getByTestId('offset')).not.toContainText('0:00'); // This might be a bad test requires that the test is not run at 0h
|
||||
await editor.getByLabel('Pause event').click();
|
||||
await editor.getByTestId('entry-1').getByLabel('Pause event').click();
|
||||
await expect(editor.getByTestId('entry-1').getByLabel('Start event')).toBeVisible();
|
||||
|
||||
// 1. initial check
|
||||
await expect(entry2.editorEvent).toContainText('9m');
|
||||
@@ -129,22 +135,29 @@ test('time until absolute', async ({ context }) => {
|
||||
|
||||
test('time until relative', async ({ context }) => {
|
||||
const editor = await context.newPage();
|
||||
editor.goto('/editor');
|
||||
await editor.goto('/editor');
|
||||
|
||||
await editor.getByRole('button', { name: 'Edit' }).click();
|
||||
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
||||
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||
await editor.getByRole('button', { name: 'Delete all' }).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(0);
|
||||
|
||||
await editor.getByRole('button', { name: 'Create Event' }).click();
|
||||
await editor.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(1);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(2);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(3);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(4);
|
||||
|
||||
await editor.getByRole('button', { name: 'Relative' }).click();
|
||||
await editor.getByTestId('entry-1').getByLabel('Start event').click();
|
||||
await expect(editor.getByTestId('entry-1').getByLabel('Pause event')).toBeVisible();
|
||||
await expect(editor.getByTestId('offset')).toContainText('0:00'); // This might be a bad test as it ruires the evaluation to happen within 1s
|
||||
await editor.getByLabel('Pause event').click();
|
||||
await editor.getByTestId('entry-1').getByLabel('Pause event').click();
|
||||
await expect(editor.getByTestId('entry-1').getByLabel('Start event')).toBeVisible();
|
||||
|
||||
await expect(editor.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('9m');
|
||||
await expect(editor.getByTestId('entry-3').getByTestId('rundown-event')).toContainText('19m');
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "4.11.0",
|
||||
"version": "4.12.0",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
@@ -41,14 +41,14 @@
|
||||
"format:check": "oxfmt --check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.60.0",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/node": "catalog:",
|
||||
"cross-env": "^7.0.3",
|
||||
"oxfmt": "^0.42.0",
|
||||
"oxlint": "^1.57.0",
|
||||
"oxlint-tsgolint": "^0.17.4",
|
||||
"oxfmt": "^0.63.0",
|
||||
"oxlint": "^1.78.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"rimraf": "catalog:",
|
||||
"turbo": "2.8.20",
|
||||
"turbo": "2.10.10",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.11.0",
|
||||
"version": "4.12.0",
|
||||
"name": "ontime-types",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"nanoid": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"ontime-types": "workspace:*",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
|
||||
Generated
+1152
-2116
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -4,10 +4,10 @@ packages:
|
||||
|
||||
catalog:
|
||||
'@types/node': 22.19.11
|
||||
rimraf: 6.0.1
|
||||
ts-essentials: 10.1.1
|
||||
rimraf: 6.1.3
|
||||
ts-essentials: 10.2.1
|
||||
typescript: 7.0.2
|
||||
vitest: 4.0.17
|
||||
vitest: 4.1.10
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
|
||||
Reference in New Issue
Block a user