Compare commits

..

3 Commits

Author SHA1 Message Date
Claude ce7717f26d refactor(countdown): drop indirection left over from the row sections
renderRow had a single call site and recomputed activeEntryId, which the
caller had already derived to pick the scroll anchor. Inlining it brings
the row back to the shape it had before the sections were introduced.

The section wrapper no longer carries flex-grow. The list is sized by its
content and never fills the column, so there is no free space to
distribute and the growth factor resolved to nothing. Sections stay flex
containers though, otherwise the row margins collapse and the rows of a
section sit closer together than the sections themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjX7D56AW8cL3FXjvtnjtL
2026-08-12 05:45:55 +00:00
Claude 0106145a45 docs(countdown): explain why the sticky offset is measured
The group card scales with the viewport, grows while live and wraps on
long titles, measuring between 91px and 171px. A constant would leave
the running event behind the pinned header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjX7D56AW8cL3FXjvtnjtL
2026-08-12 05:19:22 +00:00
Claude e4cfa1d17d feat(views): keep group visible as header for the running event
In the countdown view a user can subscribe to a group and to events
inside it. Once an event within the group started, the auto scroll
pushed the group card above the viewport, losing the context of which
group the running event belongs to. The operator view had the same
problem.

Group and its events are now wrapped in a section, and the group card is
pinned with position sticky, so it stays above the running event however
far down the group that event sits. The follow scroll offsets the sticky
header, and the follow button threshold discounts it so it keeps its
meaning.

A group holding the running event is now marked as active. In the
countdown the card keeps its background and gains a green outline, the
green fill stays reserved for the running event itself. In the operator
the card is already filled with the group colour, so the ring uses the
lighter active indicator over a dark frame to stay legible against any
group colour. Neither state was visible before: .sub--group overrode
both .sub--live and .sub--armed at equal specificity.

Also fixes the countdown handing the same selectedRef to both a group
row and its running child. React detaches refs before attaching them,
so the child unsetting the ref left it null while the group never
re-attached, silently disabling auto scroll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjX7D56AW8cL3FXjvtnjtL
2026-08-10 17:36:56 +00:00
33 changed files with 2549 additions and 1397 deletions
+7 -8
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"dependencies": {
"@base-ui/react": "1.7.0",
"@base-ui/react": "1.6.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": "^9.5.1",
"@mantine/hooks": "^8.3.7",
"@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": "^9.1.2",
"@tanstack/react-table": "^8.21.3",
"@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.7.0",
"react-icons": "5.6.0",
"react-router": "^8.0.1",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.14"
@@ -60,8 +60,7 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "5.4.0",
"@types/node": "catalog:",
"@sentry/vite-plugin": "5.1.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
@@ -73,8 +72,8 @@
"ontime-utils": "workspace:*",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "8.2.1",
"vite-plugin-compression2": "2.5.3",
"vite": "8.0.1",
"vite-plugin-compression2": "2.5.1",
"vite-plugin-svgr": "4.5.0",
"vitest": "catalog:"
}
@@ -1,5 +1,5 @@
import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
import { useDisclosure, useFullscreen } 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 } = useFullscreenDocument();
const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation();
@@ -22,6 +22,11 @@ interface UseFollowComponentProps {
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
/**
* Resolved at scroll time, for offsets which can only be measured from the DOM (eg. a sticky header).
* Takes precedence over topOffset. Keep the reference stable, it is a dependency of the follow effect.
*/
getTopOffset?: () => number;
setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
}
@@ -31,6 +36,7 @@ export default function useFollowComponent({
scrollRef,
doFollow,
topOffset = 100,
getTopOffset,
setScrollFlag,
followTrigger,
}: UseFollowComponentProps) {
@@ -44,21 +50,23 @@ export default function useFollowComponent({
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
// resolve the offset after layout, so that measured values are up to date
const offset = getTopOffset?.() ?? topOffset;
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, offset);
setScrollFlag?.(false);
});
}
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset, getTopOffset]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
(componentRef = followRef, containerRef = scrollRef, offset?: number) => {
if (componentRef && containerRef) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset);
scrollToComponent(componentRef!, containerRef!, offset ?? getTopOffset?.() ?? topOffset);
}
},
[followRef, scrollRef, topOffset],
[followRef, scrollRef, topOffset, getTopOffset],
);
return scrollToRefComponent;
+35
View File
@@ -1,3 +1,5 @@
import { AppMode } from '../ontimeConfig';
declare module '*.scss' {
const content: Record<string, string>;
export default content;
@@ -30,6 +32,39 @@ 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
*/
@@ -18,6 +18,14 @@
padding-bottom: 95vh;
}
/* a group and its events, bounds the sticky group header to its own section.
repeats the gap of the surrounding list, which no longer reaches the rows of a section */
.groupSection {
display: flex;
flex-direction: column;
gap: 2px;
}
.editPrompt {
position: fixed;
z-index: $zindex-dialog;
+18 -6
View File
@@ -1,5 +1,5 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
@@ -54,11 +54,21 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
// the group of the running event is pinned to the top of the list, the running event needs to clear it
const getStickyOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// account for the list gap, so that the rows do not touch
return header ? header.offsetHeight + 2 : 0;
}, []);
const getTopOffset = useCallback(() => selectedOffset + getStickyOffset(), [getStickyOffset]);
const scrollToComponent = useFollowComponent({
followRef: selectedRef,
scrollRef,
doFollow: !lockAutoScroll,
topOffset: selectedOffset,
getTopOffset,
followTrigger: selectedEventId,
});
@@ -86,7 +96,8 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
@@ -186,13 +197,14 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
}
return (
<Fragment key={entry.id}>
<div className={style.groupSection} key={entry.id}>
<OperatorGroup
key={entry.id}
ref={isCurrentParent ? stickyHeaderRef : undefined}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
duration={entry.duration}
isLive={isCurrentParent}
/>
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
@@ -239,7 +251,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/>
);
})}
</Fragment>
</div>
);
}
return null;
@@ -3,17 +3,32 @@
min-height: 2.5rem;
padding: 0.4rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
/* the background comes inline from getAccessibleColour, which resolves to an opaque colour
and is what keeps the rows from showing through while the header is pinned */
font-size: 1.25rem;
font-weight: 600;
/* stays visible as a header for the events of the group, bound by the group section */
position: sticky;
/* covers the list padding, otherwise the rows scroll through it */
top: -0.25rem;
z-index: 1;
display: flex;
align-items: center;
gap: 1rem;
letter-spacing: 0;
}
/* a group holding the running event is only outlined, the green fill belongs to the event row itself.
the card is already filled with the group colour, so we use the lighter active indicator over a dark
frame, which stays legible against any of them, green groups included */
.live {
box-shadow:
inset 0 0 0 2px $ui-black,
inset 0 0 0 4px $active-indicator;
}
.title {
flex: 1 1 auto;
min-width: 0;
@@ -1,6 +1,6 @@
import { CSSProperties, memo } from 'react';
import { CSSProperties, Ref, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time';
import style from './OperatorGroup.module.scss';
@@ -10,15 +10,22 @@ interface OperatorGroup {
colour: string;
count: number;
duration: number;
/** whether the group contains the currently running event */
isLive?: boolean;
ref?: Ref<HTMLDivElement>;
}
export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
function OperatorGroup({ title, colour, count, duration, isLive, ref }: OperatorGroup) {
const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour);
return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
<div
className={cx([style.group, isLive && style.live])}
style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}
ref={ref}
>
<span className={style.title}>{title}</span>
<span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
@@ -1,14 +1,15 @@
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): CuesheetColumnDef[] {
export function makeRundownColumns(customFields: CustomFields): ColumnDef<ExtendedEntry>[] {
// 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);
+34 -2
View File
@@ -103,6 +103,22 @@ $item-height: 3.5rem;
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
}
/* a subscribed group and its subscribed events, bounds the sticky header to its own section.
flex rather than block so that the row margins do not collapse, keeping the spacing between
rows of a section equal to the spacing between sections */
.sub-section {
display: flex;
flex-direction: column;
}
/* scoped to the section, the select view shows the same cards in a flat list */
.sub-section .sub--group {
position: sticky;
/* sticky insets constrain the margin box, negate the row margin so the header sits flush */
top: -2px;
z-index: 1;
}
/* ====================== LIST-ITEM ======================*/
.sub {
margin: 2px;
@@ -196,9 +212,15 @@ $item-height: 3.5rem;
.sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
background:
/* the card colour is translucent, an opaque base keeps the rows from showing through the sticky header.
split into longhands since the shorthand only takes a colour in its last layer */
background-color: var(--background-color-override, $viewer-background-color);
background-image:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
var(--card-background-color-override, $viewer-card-bg-color);
linear-gradient(
var(--card-background-color-override, $viewer-card-bg-color),
var(--card-background-color-override, $viewer-card-bg-color)
);
.sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
@@ -230,6 +252,16 @@ $item-height: 3.5rem;
}
}
/* a group holding the running event is only outlined, the green fill belongs to the event row itself */
.sub--group.sub--live {
box-shadow: inset 0 0 0 2px $active-green;
}
/* loaded but not yet running, subtle enough to not compete with the live ring */
.sub--group.sub--armed {
box-shadow: inset 0 0 0 2px $gray-1000;
}
.sub__title {
grid-area: title;
padding-bottom: 0.5rem;
@@ -1,6 +1,6 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
@@ -22,6 +22,7 @@ import {
CountdownTarget,
extendEventData,
getIsLive,
groupSubscriptionTargets,
isOutsideRange,
preferredFormat12,
preferredFormat24,
@@ -48,11 +49,25 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]);
// The group of the running event is pinned to the top of the list, the running event needs to clear it.
// This is measured rather than estimated: the card scales with the viewport (the font sizes are clamped
// against vw), it grows while live since the running event's title is added, and long titles wrap.
// Measured between 91px and 171px, so a constant would leave the running event behind the header.
const getStickyOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// account for the row margin, so that the rows do not touch
return header ? header.offsetHeight + 2 : 0;
}, []);
const scrollToComponent = useFollowComponent({
followRef: selectedRef,
scrollRef,
doFollow: !lockAutoScroll,
topOffset: 0,
getTopOffset: getStickyOffset,
followTrigger: selectedEventId,
});
@@ -79,7 +94,8 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
@@ -98,41 +114,63 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{subscribedEvents.map((event) => {
// while a group is live, surface the running event's title as the secondary line
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
{sections.map((section) => {
const rows = section.group ? [section.group, ...section.events] : section.events;
// the running event anchors the scroll, the group header stays pinned above it
const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null;
return (
<div
key={event.id}
ref={isLive ? selectedRef : undefined}
className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue}
>
<div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
<div key={section.group?.id ?? rows[0].id} className='sub-section'>
{rows.map((event) => {
// while a group is live, surface the running event's title as the secondary line
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
// only ever hand the ref to a single row, sharing it would null it out on the next commit
const isAnchor = isLive && (anchorId === null || event.id === anchorId);
const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined;
const countdownEvent = extendEventData(
event,
currentDay,
actualStart,
plannedStart,
offset,
mode,
reportData,
);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return (
<div
key={event.id}
ref={rowRef}
className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue}
>
<div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
})}
</div>
);
})}
@@ -1,7 +1,7 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { resolveSubscriptionTarget } from './countdown.utils';
import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils';
/**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
@@ -126,3 +126,89 @@ describe('resolveSubscriptionTarget()', () => {
expect(result?.liveEntry).toBeNull();
});
});
describe('groupSubscriptionTargets()', () => {
/**
* Resolves a group the same way the view does, so that the tests exercise the real target shape
* (a resolved group carries type Event, so the helper cannot rely on the entry type)
*/
function resolveGroup(group: ExtendedEntry<OntimeGroup>, flat: ExtendedEntry<OntimeEntry>[]): CountdownTarget {
const resolved = resolveSubscriptionTarget(group, flat);
if (resolved === null) {
throw new Error('test setup: group has no playable children');
}
return resolved;
}
it('returns no sections for an empty subscription list', () => {
expect(groupSubscriptionTargets([])).toEqual([]);
});
it('gives each ungrouped event its own section', () => {
const e1 = makeEvent({ id: 'e1' });
const e2 = makeEvent({ id: 'e2' });
expect(groupSubscriptionTargets([e1, e2])).toEqual([
{ group: null, events: [e1] },
{ group: null, events: [e2] },
]);
});
it('absorbs the children of a subscribed group into its section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const c2 = makeEvent({ id: 'c2', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1, c2]);
expect(groupSubscriptionTargets([resolved, c1, c2])).toEqual([{ group: resolved, events: [c1, c2] }]);
});
it('keeps a subscribed group with no subscribed children as an empty section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved])).toEqual([{ group: resolved, events: [] }]);
});
it('does not absorb an event which belongs to a different group', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const flat = [group1, c1, group2, c2];
const resolved1 = resolveGroup(group1, flat);
const resolved2 = resolveGroup(group2, flat);
expect(groupSubscriptionTargets([resolved1, c1, resolved2, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: resolved2, events: [c2] },
]);
});
it('does not absorb an event whose parent group is not subscribed', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const resolved1 = resolveGroup(group1, [group1, c1, group2, c2]);
// only the first group is subscribed, so the second group's child stands alone
expect(groupSubscriptionTargets([resolved1, c1, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: null, events: [c2] },
]);
});
it('closes a section when an ungrouped event follows a group', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const e1 = makeEvent({ id: 'e1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved, c1, e1])).toEqual([
{ group: resolved, events: [c1] },
{ group: null, events: [e1] },
]);
});
});
@@ -252,6 +252,44 @@ export function resolveSubscriptionTarget(
};
}
/**
* A subscribed group along with the subscribed events which belong to it.
* Events without a subscribed parent group form their own section with no group.
*/
export type CountdownSection = {
group: CountdownTarget | null;
events: CountdownTarget[];
};
/**
* Folds the flat, rundown ordered subscription targets into sections.
* A group opens a section which absorbs the following targets that declare it as parent,
* which allows the group to be rendered as a sticky header for its own events.
*/
export function groupSubscriptionTargets(targets: CountdownTarget[]): CountdownSection[] {
const sections: CountdownSection[] = [];
let openSection: CountdownSection | null = null;
for (const target of targets) {
// resolveSubscriptionTarget spreads the first child, so we cannot rely on the entry type here
if (target.isGroup) {
openSection = { group: target, events: [] };
sections.push(openSection);
continue;
}
if (openSection?.group && target.parent === openSection.group.id) {
openSection.events.push(target);
continue;
}
openSection = null;
sections.push({ group: null, events: [target] });
}
return sections;
}
export function extendEventData(
event: CountdownTarget,
currentDay: number,
@@ -7,13 +7,14 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import { ColumnDef } from '@tanstack/react-table';
import { PropsWithChildren } from 'react';
import type { CuesheetColumnDef } from '../cuesheet-table/cuesheetTable.features';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { useColumnOrder } from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps {
columns: CuesheetColumnDef[];
columns: ColumnDef<ExtendedEntry>[];
tableRoot?: 'editor' | 'cuesheet';
}
@@ -1,5 +1,5 @@
import { useTableNav } from '@table-nav/react';
import { useTable } from '@tanstack/react-table';
import { ColumnDef, Table, getCoreRowModel, useReactTable } 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,17 +29,12 @@ 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: CuesheetColumnDef[];
columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode;
@@ -125,8 +120,7 @@ export default function CuesheetTable({
const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot);
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
const table = useTable({
features: cuesheetTableFeatures,
const table = useReactTable({
data: flatRundown,
columns,
columnResizeMode: 'onChange',
@@ -137,6 +131,7 @@ export default function CuesheetTable({
},
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
meta,
});
@@ -200,7 +195,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.state.columnResizing, table.state.columnSizing]);
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
const allLeafColumns = table.getAllLeafColumns();
const { rows } = table.getRowModel();
@@ -219,7 +214,9 @@ export default function CuesheetTable({
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
const fixedHeaderContent = useCallback(() => {
return table.getHeaderGroups().map((headerGroup) => {
const HeaderComponent = table.state.columnResizing.isResizingColumn ? CuesheetHeader : SortableCuesheetHeader;
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
? CuesheetHeader
: SortableCuesheetHeader;
// if the table is being resized, we render non-sortable headers to avoid performance issues
return (
@@ -282,8 +279,8 @@ interface CuesheetVirtuosoContext {
columnSizeVars: { [key: string]: number };
cursor: string | null;
listeners: ReturnType<typeof useTableNav>['listeners'];
rows: ReturnType<CuesheetTableInstance['getRowModel']>['rows'];
table: CuesheetTableInstance;
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
table: Table<ExtendedEntry>;
handleAddNew?: (type: SupportedEntry) => void;
}
@@ -1,16 +1,16 @@
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { FlexRender } from '@tanstack/react-table';
import { HeaderGroup, 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: CuesheetHeaderGroup;
headerGroup: HeaderGroup<ExtendedEntry>;
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={header} />}
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</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={header} />}
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableCell>
);
})}
@@ -1,14 +1,14 @@
import { FlexRender } from '@tanstack/react-table';
import { EntryId, RGBColour, SupportedEntry } from 'ontime-types';
import { Table, flexRender } from '@tanstack/react-table';
import { EntryId, OntimeEntry, 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: CuesheetTable;
table: Table<ExtendedEntry<OntimeEntry>>;
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={cell} />
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
@@ -1,12 +1,12 @@
import { FlexRender } from '@tanstack/react-table';
import { Table, 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: CuesheetTable;
table: Table<ExtendedEntry>;
injectedStyles?: CSSProperties;
hasCursor?: boolean;
}
@@ -76,7 +76,7 @@ function GroupRow({
}}
role='cell'
>
<FlexRender cell={cell} />
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
@@ -1,14 +1,14 @@
import { FlexRender } from '@tanstack/react-table';
import { Table, 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: CuesheetTable;
table: Table<ExtendedEntry>;
injectedStyles?: CSSProperties;
hasCursor?: boolean;
}
@@ -102,7 +102,7 @@ function MilestoneRow({
}}
tabIndex={-1}
>
{canRender && <FlexRender cell={cell} />}
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
@@ -1,8 +1,9 @@
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 { CuesheetHeaderCell } from '../cuesheetTable.features';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import style from '../CuesheetTable.module.scss';
@@ -47,7 +48,7 @@ export function TableCell({ colSpan, injectedStyles, children, draggable }: Sort
}
interface DraggableProps {
header: CuesheetHeaderCell;
header: Header<ExtendedEntry, unknown>;
}
export function Draggable({ header }: DraggableProps) {
@@ -1,3 +1,4 @@
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';
@@ -7,7 +8,6 @@ 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: CuesheetCellContext['column']): string {
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
}
function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
if (!table.options.meta) {
return null;
}
@@ -60,7 +60,7 @@ function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
);
}
function MakeEnd({ getValue, row, table, column }: CuesheetCellContext) {
function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
if (!table.options.meta) {
return null;
}
@@ -100,7 +100,7 @@ function MakeEnd({ getValue, row, table, column }: CuesheetCellContext) {
);
}
function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
if (!table.options.meta) {
return null;
}
@@ -131,7 +131,7 @@ function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
);
}
function MakeMultiLineField({ row, column, table }: CuesheetCellContext) {
function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -160,7 +160,7 @@ function MakeMultiLineField({ row, column, table }: CuesheetCellContext) {
);
}
function LazyImage({ row, column, table }: CuesheetCellContext) {
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -178,7 +178,7 @@ function LazyImage({ row, column, table }: CuesheetCellContext) {
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
}
function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -207,7 +207,7 @@ function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
);
}
function MakeFlagField({ row }: CuesheetCellContext) {
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
const event = row.original;
if (!isOntimeEvent(event) || !event.flag) {
return null;
@@ -215,7 +215,7 @@ function MakeFlagField({ row }: CuesheetCellContext) {
return <FlagCell />;
}
function MakeCustomField({ row, column, table }: CuesheetCellContext) {
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
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,
): CuesheetColumnDef[] {
const columnsDef: CuesheetColumnDef[] = [];
): ColumnDef<ExtendedEntry>[] {
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
if (canRead('flag')) {
@@ -2,6 +2,7 @@ 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';
@@ -9,9 +10,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';
@@ -36,7 +37,7 @@ type TableModeControls = {
};
interface CuesheetTableHeaderToolbarProps {
columns: CuesheetColumn[];
columns: Column<ExtendedEntry, unknown>[];
optionsStore: TableHeaderOptionsStore;
handleResetResizing: () => void;
handleResetReordering: () => void;
@@ -110,7 +111,7 @@ interface ViewSettingsProps {
}
interface ColumnSettingsProps {
columns: CuesheetColumn[];
columns: Column<ExtendedEntry, unknown>[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
@@ -202,7 +203,7 @@ function ColumnSettings({
return (
<Editor.Label key={`${column.id}-${visible}`} className={style.option}>
<Checkbox defaultChecked={visible} onCheckedChange={(checked) => column.toggleVisibility(checked)} />
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} />
{columnHeader as ReactNode}
</Editor.Label>
);
@@ -1,74 +0,0 @@
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 { ColumnSizingState, Updater } from '@tanstack/react-table';
import { ColumnDef, 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 { CuesheetColumnDef } from './cuesheetTable.features';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
type TableRoot = 'editor' | 'cuesheet';
@@ -38,7 +38,7 @@ export function useColumnSizes(tableRoot: TableRoot = 'cuesheet') {
};
}
export function useColumnOrder(columns: CuesheetColumnDef[], tableRoot: TableRoot = 'cuesheet') {
export function useColumnOrder(columns: ColumnDef<ExtendedEntry>[], tableRoot: TableRoot = 'cuesheet') {
const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]);
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
+2 -2
View File
@@ -14,8 +14,8 @@
"main": "src/main.js",
"devDependencies": {
"electron": "38.2.1",
"electron-builder": "26.15.3",
"wait-on": "^9.0.0"
"electron-builder": "26.9.1",
"wait-on": "^7.2.0"
},
"scripts": {
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
+3 -3
View File
@@ -10,7 +10,7 @@
"cookie": "1.0.2",
"cookie-parser": "1.4.7",
"cors": "2.8.6",
"dotenv": "^17.0.0",
"dotenv": "^16.0.1",
"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.28.0",
"esbuild": "^0.24.0",
"ontime-types": "workspace:*",
"server-timing": "^3.3.3",
"ts-essentials": "catalog:",
"tsx": "^4.23.12",
"tsx": "^4.19.2",
"typescript": "catalog:",
"vitest": "catalog:"
},
@@ -353,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',
}
/**
+6 -3
View File
@@ -20,9 +20,12 @@ 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;
-19
View File
@@ -6,25 +6,6 @@ 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');
+5 -5
View File
@@ -41,14 +41,14 @@
"format:check": "oxfmt --check"
},
"devDependencies": {
"@playwright/test": "1.62.1",
"@playwright/test": "1.60.0",
"@types/node": "catalog:",
"cross-env": "^7.0.3",
"oxfmt": "^0.63.0",
"oxlint": "^1.78.0",
"oxlint-tsgolint": "^7.0.2001",
"oxfmt": "^0.42.0",
"oxlint": "^1.57.0",
"oxlint-tsgolint": "^0.17.4",
"rimraf": "catalog:",
"turbo": "2.10.10",
"turbo": "2.8.20",
"typescript": "catalog:"
},
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
-1
View File
@@ -14,7 +14,6 @@
"nanoid": "^6.0.0"
},
"devDependencies": {
"@types/node": "catalog:",
"ontime-types": "workspace:*",
"typescript": "catalog:",
"vitest": "catalog:"
+2115 -1151
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -4,10 +4,10 @@ packages:
catalog:
'@types/node': 22.19.11
rimraf: 6.1.3
ts-essentials: 10.2.1
rimraf: 6.0.1
ts-essentials: 10.1.1
typescript: 7.0.2
vitest: 4.1.10
vitest: 4.0.17
allowBuilds:
'@parcel/watcher': true