Compare commits

...

47 Commits

Author SHA1 Message Date
alex-arc 99a2f58502 fix isLoadedRundown the now rundown id is provided 2026-07-26 16:33:05 +02:00
alex-arc 535717fa90 the rundown title list should also folow the runown selection 2026-07-26 16:07:13 +02:00
alex-arc 477777552c fix rebase 2026-07-26 15:43:20 +02:00
alex-arc df2d083a16 chore: lint 2026-07-26 15:41:51 +02:00
alex-arc 079a2ea96d feat: select rundown in list view 2026-07-26 15:41:51 +02:00
alex-arc c50c84f50b refactor: use context rundown in finder 2026-07-26 15:41:51 +02:00
alex-arc 58dce0a18c refactor: react state for cuesheet v editor layout 2026-07-26 15:41:51 +02:00
alex-arc b2f9b812bd refactor: rundown id context 2026-07-26 15:41:51 +02:00
alex-arc 35d2474797 fixup! refactor: useSuspenseQuery for rundown list 2026-07-26 15:41:41 +02:00
alex-arc b9f5a0a82b refactor: useSuspenseQuery for rundown list 2026-07-26 15:41:41 +02:00
alex-arc 26a58a9241 fixup! refactor: useSuspenseQuery for project list 2026-07-26 15:41:41 +02:00
alex-arc 588b04c46b refactor: useSuspenseQuery for project list 2026-07-26 15:41:41 +02:00
Carlos Valente 5cf36f049a refactor(modal): allow wider modals 2026-07-25 14:28:45 +02:00
Carlos Valente c6248b0c72 refactor(import): improve preview UI 2026-07-25 14:28:45 +02:00
Alex Christoffer Rasmussen 2a890cf2b3 fix: issue where a countToEnd would lead to incorrect expected times (#2149)
* fix: issue where a count-to-end would lead to incorrect expected times

* fix: include add time in overtime when countToEnd

* fix: ui and server use same calculation for expected end
2026-07-25 11:22:24 +02:00
Carlos Valente a8c611911d refactor(rundown): new entries are appended to rundown 2026-07-22 07:17:39 +02:00
Carlos Valente 8363f06a5c chore(rundown): improve documentation of insert logic 2026-07-22 07:17:39 +02:00
will@t1d.dev 5f040092cb fix: timer-legacy view not served in desktop distributions
The server serves html/timer-legacy.html and html/login.html from disk
at runtime, relative to the bundled server. The Docker image copies
these files but the electron packaging did not, so the view 404ed in
all desktop distributions.

Additionally, AppImages mount at /tmp/.mount_*, a hidden directory.
Express sendFile refuses paths containing dot-segments by default
(returns 404 without touching disk), so the view failed on Linux even
with the file packaged. Allow dotfiles for this route; the request
path is fixed so no user input is affected.
2026-07-19 10:02:45 +02:00
Carlos Valente 347c748dd9 feat(rundown): add shortcut to jump to current element 2026-07-18 17:03:02 +02:00
Carlos Valente f234a1f892 fix(rundown): prevent skipped events from influencing day calculations 2026-07-18 16:52:36 +02:00
Carlos Valente 0c5e87b7e7 fix(ui): prevent warning indicator from overflowing container 2026-07-18 16:52:36 +02:00
Carlos Valente 64082ceac0 refactor(rundown): improve visibility of count to end 2026-07-18 16:45:55 +02:00
Carlos Valente f598e8dab1 refactor(time control): improve handling of overtime and count to end 2026-07-18 16:45:55 +02:00
Carlos Valente 0c84a3ff9e fix: delays account for midnight 2026-07-18 16:45:55 +02:00
Carlos Valente c8760b5e9c bump version to 4.11.0 2026-07-18 15:24:25 +02:00
Claude 34360a5b8f feat(import): add merge strategy and new-rundown destination to spreadsheet import
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-07-17 22:26:19 +02:00
Claude 5e6debcce0 fix(views): center timer digits with flexbox for embedded browser compatibility
The Stage Timer and PiP Timer views relied on `align-content: center` to
vertically center the timer digits inside `.timer-container`, which is a
plain block element. Aligning children of a block container via
`align-content` only works in Chromium 123+ (Firefox 125+, Safari 17.4+).

Embedded browsers such as vMix Browser Input (CEF V115) and other older
CEF/Chromium-based production tools ignore the property, so the digits
fall back to the top of the container while every other element renders
correctly.

Make `.timer-container` a real flex column and center with
`justify-content: center`, which is universally supported and matches the
centering approach used elsewhere in the codebase. The removed
`justify-self`/`align-self` were no-ops on a full-width flex item.

Fixes #2126

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T6ENZ3r6JXZb1fpYw2oNY
2026-07-17 20:59:49 +02:00
Carlos Valente c191786e50 refactor(navigation): clear params from editor drawer 2026-07-17 15:50:42 +02:00
Carlos Valente 6911e37c18 refactor(automation): improve automation UX 2026-07-17 10:53:45 +02:00
Claude 86b2132cdc feat(automation): expose group title and allow setting secondary text
Surface {{groupNow.*}} template variables (title, note, colour, times,
custom fields) in the automation template autocomplete so events inside a
group can reference their group. The runtime store already carries
groupNow, so substitution and filters worked already; this makes it
discoverable.

Extend the message-secondary action with an optional text field so an
automation can set the secondary message content, not just its source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZYPZVdZLWowU7DyzGkWyy
2026-07-17 10:53:45 +02:00
Carlos Valente 25f0dba83a chore: add codegraph to gitignore 2026-07-16 10:46:10 +02:00
Carlos Valente daa032e92c ux(finder): add navigation shortcuts 2026-07-14 12:04:44 +02:00
Carlos Valente ae65ec97d6 fix(shortcut): correct shortcut to delete 2026-07-14 12:04:44 +02:00
Carlos Valente e7e4302100 refactor(shortcuts): improve readability of info element 2026-07-14 12:04:44 +02:00
Carlos Valente 7e7d35980a refactor(op): improve status recognition 2026-07-14 11:58:59 +02:00
Carlos Valente 5001dda284 feat(op): add group visual relation 2026-07-14 11:58:59 +02:00
Carlos Valente a75d35bdc0 refactor(op): improve delay indicator styles 2026-07-14 11:58:59 +02:00
Claude 2e631194e1 fix(ui): improve progress bar with local interpolation
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-07-14 11:25:22 +02:00
Carlos Valente f39892dc6e feat(navigation): persist view params customisation 2026-07-13 18:44:38 +02:00
Carlos Valente 6bcfd87851 refactor(rundown): show locked duration group indicator 2026-07-13 17:34:11 +02:00
Claude 4addb50ca4 feat(countdown): allow counting down to a group 2026-07-13 17:29:56 +02:00
Carlos Valente 161ab3fe13 refactor(timer): elapsed is active time from start 2026-07-13 15:34:31 +02:00
Carlos Valente 4f4626a163 refactor: allow larger settings panels 2026-07-13 15:33:23 +02:00
Claude 82d5f02ea0 refactor(mcp): guide agents through creating clean rundowns 2026-07-13 15:33:23 +02:00
Claude 05aaeddf22 resolver: drop dead ignoreDeprecations from tsconfig.dts.json
Confirmed empirically that it now suppresses nothing under TS 7.0.2:
compiling tsconfig.dts.json directly with this flag set to "6.0",
"7.0", or removed entirely all produce identical (clean) output. It
was a leftover suppression the previous tsdown mode never actually
needed once nothing in the config triggers a deprecation warning.
2026-07-13 14:27:31 +02:00
Claude 949ab2b7dc Upgrade TypeScript to 7.0.2 (native Go compiler)
TypeScript 7 ships as a native Go binary and drastically trims the
package's public JS API surface (no more `main`, and `exports['.']`
now only resolves to lib/version.cjs) since the programmatic compiler
API isn't stable again until 7.1. This broke apps/resolver's tsdown
build, which used rolldown-plugin-dts' tsc-program mode (import ts
from 'typescript'; ts.createProgram(...)) for declaration generation.

Fix by switching that build to isolatedDeclarations, which lets
rolldown-plugin-dts generate .d.ts via its oxc-based path instead of
the TS compiler API entirely. That surfaced two isolatedDeclarations
violations in ontime-types (array/Object.keys initializers lacking
explicit annotations), fixed with explicit `string[]` types matching
their existing inferred type. Also bumped tsdown to pick up its latest
rolldown-plugin-dts, and disabled allowJs in tsconfig.dts.json since
it's incompatible with isolatedDeclarations.

Full workspace typecheck, lint, build, and test suite (1186 tests)
pass under TypeScript 7.0.2.
2026-07-13 14:27:31 +02:00
Alex Christoffer Rasmussen f12d3ca59c Small cleanup (#2114)
* refactor: propper memo for `ScheduleContext` value

* chore: removed unused file `cuesheet.utils.ts`

* fix: memoize event in ScheduleProvider
2026-07-11 22:20:32 +02:00
197 changed files with 6377 additions and 1851 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.10.0", "version": "4.11.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.10.0", "version": "4.11.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+10
View File
@@ -6,6 +6,7 @@ import {
ProjectRundownsList, ProjectRundownsList,
RenumberCues, RenumberCues,
Rundown, Rundown,
RundownImportPayload,
TransientEventPayload, TransientEventPayload,
} from 'ontime-types'; } from 'ontime-types';
@@ -82,6 +83,15 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
return axios.delete(`${rundownPath}/${rundownId}`); return axios.delete(`${rundownPath}/${rundownId}`);
} }
/**
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
*/
export async function importRundownWithOptions(
payload: RundownImportPayload,
): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/import`, payload);
}
// #endregion operations on project rundowns ====================== // #endregion operations on project rundowns ======================
// #region operations on rundown entries ========================== // #region operations on rundown entries ==========================
@@ -0,0 +1,14 @@
.kbd {
display: inline-block;
min-width: 1.5rem;
padding: 0.0625rem 0.375rem;
border-radius: 2px;
background-color: $gray-1100;
color: $ui-white;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.4);
font-family: monospace;
font-size: calc(1rem - 4px);
line-height: 1.5;
text-align: center;
white-space: nowrap;
}
@@ -0,0 +1,7 @@
import { PropsWithChildren } from 'react';
import style from './Kbd.module.scss';
export default function Kbd({ children }: PropsWithChildren) {
return <kbd className={style.kbd}>{children}</kbd>;
}
@@ -20,7 +20,7 @@
.wide { .wide {
top: 4vh; top: 4vh;
min-width: min(1280px, 96vw); min-width: min(1280px, 96vw);
max-width: min(1600px, 96vw); max-width: min(1800px, 98vw);
height: 88vh; height: 88vh;
max-height: 88vh; max-height: 88vh;
display: flex; display: flex;
@@ -38,8 +38,6 @@ $progress-bar-br: 3px;
.multiprogress-bar__indicator-bar { .multiprogress-bar__indicator-bar {
background-color: var(--background-color-override, $ui-black); background-color: var(--background-color-override, $ui-black);
opacity: 0.8; opacity: 0.8;
transition: 1s linear;
transition-property: width;
.multiprogress-bar--ignore-css-override & { .multiprogress-bar--ignore-css-override & {
background-color: $ui-black; background-color: $ui-black;
@@ -1,5 +1,6 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import { getProgress } from '../../utils/getProgress'; import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -34,7 +35,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '', className = '',
} = props; } = props;
const percentRemaining = 100 - getProgress(now, complete); const percentRemaining = 100 - useAnimatedProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0; const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0; const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0; const isOvertime = now !== null && now < 0;
@@ -1,6 +1,8 @@
import { useDisclosure, useHotkeys } from '@mantine/hooks'; import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react'; import { memo } from 'react';
import { useSearchParams } from 'react-router';
import { hasCustomParams, useSavedViewParams } from '../../stores/savedViewParams';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store'; import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation'; import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
@@ -17,6 +19,9 @@ export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) { function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore(); const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
const savedParams = useSavedViewParams((store) => store.params);
const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0;
useHotkeys([ useHotkeys([
[ [
@@ -46,6 +51,7 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
<FloatingNavigation <FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle} toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer} toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges}
/> />
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />} {!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
</> </>
@@ -1,4 +1,29 @@
.linkIcon { .trailing {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-left: auto; margin-left: auto;
}
.linkIcon {
@include rotate-fourty-five; @include rotate-fourty-five;
} }
.indicator {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
flex-shrink: 0;
}
.clear {
opacity: 0.6;
transition: opacity $transition-time-action;
&:hover,
&:focus-visible {
opacity: 1;
}
}
@@ -1,9 +1,17 @@
import { PropsWithChildren } from 'react'; import { MouseEvent, PropsWithChildren } from 'react';
import { IoArrowUp } from 'react-icons/io5'; import { IoArrowUp, IoCloseOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router'; import { useLocation, useNavigate, useSearchParams } from 'react-router';
import { useElectronEvent } from '../../../hooks/useElectronEvent'; import { useElectronEvent } from '../../../hooks/useElectronEvent';
import {
hasCustomParams,
reservedParams,
stripReservedParams,
useSavedViewParams,
} from '../../../stores/savedViewParams';
import { handleLinks } from '../../../utils/linkUtils'; import { handleLinks } from '../../../utils/linkUtils';
import IconButton from '../../buttons/IconButton';
import Tooltip from '../../tooltip/Tooltip';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
import style from './ClientLink.module.scss'; import style from './ClientLink.module.scss';
@@ -16,32 +24,104 @@ interface ClientLinkProps {
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) { export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent(); const { isElectron } = useElectronEvent();
const navigate = useNavigate();
if (isElectron) { if (isElectron) {
return ( return (
<NavigationMenuItem <ElectronNavigationItem current={current} to={to} postAction={postAction}>
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children} {children}
<IoArrowUp className={style.linkIcon} /> </ElectronNavigationItem>
</NavigationMenuItem>
); );
} }
return ( return (
<NavigationMenuItem <BrowserNavigationItem current={current} to={to} postAction={postAction}>
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children} {children}
</BrowserNavigationItem>
);
}
function ElectronNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigateToLink = () => {
handleLinks(to);
postAction?.();
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
<span className={style.trailing}>
<IoArrowUp className={style.linkIcon} />
</span>
</NavigationMenuItem>
);
}
function BrowserNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { params: savedParams, save, clear } = useSavedViewParams();
/**
* Save the params of the view we are leaving and resolve the destination,
* restoring any params previously saved for the target view.
*/
const resolveDestination = () => {
const currentView = location.pathname.replace(/^\//, '');
save(currentView, stripReservedParams(location.search));
const restored = savedParams[to];
return `${to}${restored ? `?${restored}` : ''}`;
};
const isCustomised = current ? hasCustomParams(searchParams) : Boolean(savedParams[to]);
const navigateToLink = () => {
const destination = resolveDestination();
navigate(`/${destination}`);
postAction?.();
};
/**
* Clear the saved settings for this route without navigating to it.
* When it is the current view, also reset the live URL to its defaults.
*/
const clearViewSettings = (event: MouseEvent) => {
event.stopPropagation();
clear(to);
// if mounted, clear the URL params
if (current) {
const preserved = new URLSearchParams();
reservedParams.forEach((key) => {
const value = searchParams.get(key);
if (value !== null) preserved.set(key, value);
});
setSearchParams(preserved);
}
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
{isCustomised && (
<span className={style.trailing}>
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
<Tooltip
text='Reset to default'
render={
<IconButton
variant='ghosted-white'
size='small'
className={style.clear}
aria-label='Reset to default'
onClick={clearViewSettings}
/>
}
>
<IoCloseOutline />
</Tooltip>
</span>
)}
</NavigationMenuItem> </NavigationMenuItem>
); );
} }
@@ -22,3 +22,19 @@
top: 0; top: 0;
z-index: $zindex-nav; z-index: $zindex-nav;
} }
.buttonWithIndicator {
position: relative;
display: inline-flex;
}
.indicator {
position: absolute;
top: -0.15rem;
right: -0.15rem;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
}
@@ -9,9 +9,10 @@ import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps { interface FloatingNavigationProps {
toggleMenu?: () => void; toggleMenu?: () => void;
toggleSettings?: () => void; toggleSettings?: () => void;
hasSavedChanges?: boolean;
} }
export default function FloatingNavigation({ toggleMenu, toggleSettings }: FloatingNavigationProps) { export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSavedChanges }: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity(true); const isButtonShown = useFadeOutOnInactivity(true);
return ( return (
@@ -20,15 +21,18 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings }: Float
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])} className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
> >
{toggleMenu && ( {toggleMenu && (
<IconButton <div className={style.buttonWithIndicator}>
variant='subtle-white' <IconButton
size='xlarge' variant='subtle-white'
onClick={toggleMenu} size='xlarge'
aria-label='toggle menu' onClick={toggleMenu}
data-testid='navigation__toggle-menu' aria-label='toggle menu'
> data-testid='navigation__toggle-menu'
<IoApps /> >
</IconButton> <IoApps />
</IconButton>
{hasSavedChanges && <span className={style.indicator} data-testid='navigation__saved-indicator' />}
</div>
)} )}
{toggleSettings && ( {toggleSettings && (
<IconButton <IconButton
@@ -14,6 +14,4 @@ $progress-bar-br: 3px;
.progress-bar__indicator { .progress-bar__indicator {
height: $progress-bar-size; height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color); background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear;
transition-property: width;
} }
@@ -1,6 +1,6 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress'; import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import './ProgressBar.scss'; import './ProgressBar.scss';
@@ -12,7 +12,7 @@ interface ProgressBarProps {
export default function ProgressBar(props: ProgressBarProps) { export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, className } = props; const { current, duration, className } = props;
const progress = getProgress(current, duration); const progress = useAnimatedProgress(current, duration);
return ( return (
<div className={`progress-bar__bg ${className}`}> <div className={`progress-bar__bg ${className}`}>
@@ -4,7 +4,9 @@
color: $white-10; color: $white-10;
.empty { .empty {
width: 100%; display: block;
width: min(100%, 24rem);
margin-inline: auto;
opacity: 0.8; opacity: 0.8;
} }
@@ -13,6 +15,6 @@
margin-inline: auto; margin-inline: auto;
font-weight: 600; font-weight: 600;
font-size: 2em; font-size: 2em;
max-width: 600px; max-width: min(100%, 600px);
} }
} }
@@ -6,6 +6,7 @@ import { useSearchParams } from 'react-router';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
import Info from '../info/Info'; import Info from '../info/Info';
@@ -27,6 +28,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings(); const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore(); const { isOpen, close } = useViewParamsEditorStore();
const clearSavedParams = useSavedViewParams((store) => store.clear);
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions); const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
@@ -36,6 +38,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
}; };
const resetParams = () => { const resetParams = () => {
clearSavedParams(target);
setSearchParams(getPreservedParams()); setSearchParams(getPreservedParams());
}; };
@@ -1,15 +1,15 @@
import { PropsWithChildren, createContext, useContext } from 'react'; import { PropsWithChildren, createContext, useContext } from 'react';
import { useEntryActions } from '../hooks/useEntryAction'; import { useEntryActions, useScopedEntryActions } from '../hooks/useEntryAction';
import { useRundownSelectionContext } from './RundownSelectionContext';
type EntryActionsContextValue = ReturnType<typeof useEntryActions>; type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null); const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
interface EntryActionsProviderProps extends PropsWithChildren { export function EntryActionsProvider({ children }: PropsWithChildren) {
actions: EntryActionsContextValue; const { effectiveRundownId } = useRundownSelectionContext();
} const actions = useScopedEntryActions(effectiveRundownId);
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>; return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
} }
@@ -0,0 +1,120 @@
import { Maybe, ProjectRundown } from 'ontime-types';
import { PropsWithChildren, createContext, startTransition, useCallback, useContext, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { useNavigate } from 'react-router';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
export type RundownScopeValue = {
loadedRundownId: string;
selectedRundownId: Maybe<string>;
isLoadedRundown: boolean;
effectiveRundownId: string;
selectRundownId: (val: Maybe<string>) => void;
rundowns: ProjectRundown[];
};
const RundownScopeContext = createContext<RundownScopeValue | null>(null);
export function RundownSelectionContextProvider({ children }: PropsWithChildren) {
'use memo';
const { data } = useProjectRundowns();
const { loaded, rundowns } = data;
const [selectedRundownId, setSelectedRundownId] = useSelectRundownFromParams();
const selectRundownId = useCallback(
(rundownId: Maybe<string>) => {
startTransition(() => {
if (rundowns.find((entry) => entry.id === rundownId)) setSelectedRundownId(rundownId);
else setSelectedRundownId(null);
});
},
[rundowns, setSelectedRundownId],
);
const effectiveRundownId = selectedRundownId ? selectedRundownId : loaded;
const isLoadedRundown = effectiveRundownId === loaded;
useEffect(() => {
if (!rundowns.find((entry) => entry.id === effectiveRundownId)) setSelectedRundownId(null);
}, [rundowns, effectiveRundownId, setSelectedRundownId]);
const value = useMemo(
(): RundownScopeValue => ({
loadedRundownId: loaded,
isLoadedRundown,
selectedRundownId,
effectiveRundownId,
selectRundownId,
rundowns,
}),
[loaded, isLoadedRundown, selectedRundownId, effectiveRundownId, selectRundownId, rundowns],
);
return <RundownScopeContext.Provider value={value}>{children}</RundownScopeContext.Provider>;
}
export function useRundownSelectionContext() {
const context = useContext(RundownScopeContext);
if (!context) {
throw new Error('useRundownScopeSelection requires a RundownSelectionContextProvider');
}
return context;
}
const rundownParam = 'rundownId';
export function useSelectRundownFromParams(): [Maybe<string>, (id: Maybe<string>) => void] {
'use memo';
const [searchParams, setSearchParams] = useSearchParams();
const selectedRundownId = searchParams.get(rundownParam);
const setSelectedRundownId = useCallback(
(id: Maybe<string>) => {
if (id === null) {
setSearchParams((searchParams) => {
searchParams.delete(rundownParam);
return searchParams;
});
} else {
setSearchParams((searchParams) => {
searchParams.set(rundownParam, id);
return searchParams;
});
}
},
[setSearchParams],
);
return [selectedRundownId, setSelectedRundownId];
}
/**
*
* mutates the provided `searchParams`
*/
export function setSelectRundownInParams(id: Maybe<string>, searchParams: URLSearchParams): void {
if (id === null) {
searchParams.delete(rundownParam);
} else {
searchParams.set(rundownParam, id);
}
}
export function useDirectLinkToBackgroundEdit() {
const navigate = useNavigate();
const [search] = useSearchParams();
return useCallback(
async (rundownId: string) => {
setSelectRundownInParams(rundownId, search);
navigate({
pathname: '/cuesheet',
search: search.toString(),
});
},
[navigate, search],
);
}
@@ -0,0 +1,101 @@
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import { useMemo } from 'react';
import { useRundownSelectionContext } from '../context/RundownSelectionContext';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useFlatRundown, useRundown } from './useRundown';
export function useContextRundownEditModal() {
'use memo';
const { effectiveRundownId } = useRundownSelectionContext();
const { data: rundown } = useRundown(effectiveRundownId);
return { rundown };
}
export function useContextRundownCueRenumberModal() {
'use memo';
const { effectiveRundownId } = useRundownSelectionContext();
const { data } = useRundown(effectiveRundownId);
const { flatOrder } = data;
return { flatOrder };
}
export function useContextRundownList() {
'use memo';
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
const loadedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
const { data: rundown, status } = useRundown(effectiveRundownId);
const rundownMetadata = useMemo(
() => getRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundown,
rundownMetadata,
status,
isLoadedRundown,
}),
[rundown, rundownMetadata, status, isLoadedRundown],
);
}
export function useContextRundownTitleList() {
'use memo';
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
const loadedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
const { data: rundown, status } = useRundown(effectiveRundownId);
const flatRundown = useMemo(() => {
const flatData = getFlatRundownMetadata(rundown, effectiveSelectedEventId);
return flatData.filter(isOntimeEvent) as ExtendedEntry<OntimeEvent>[];
}, [effectiveSelectedEventId, rundown]);
return useMemo(
() => ({
flatRundown,
status,
isLoadedRundown,
}),
[status, isLoadedRundown, flatRundown],
);
}
export function useContextRundownTable() {
'use memo';
const { effectiveRundownId, isLoadedRundown } = useRundownSelectionContext();
const loadedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? loadedEventId : null;
const { data: rundown, status } = useRundown(effectiveRundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
flatRundown,
status,
loadedEventId,
}),
[flatRundown, status, loadedEventId],
);
}
export function useContextRundownFinder() {
'use memo';
const { effectiveRundownId } = useRundownSelectionContext();
const { data: rundown, status } = useFlatRundown(effectiveRundownId);
return useMemo(
() => ({
rundown,
rundownId: effectiveRundownId,
status,
}),
[rundown, status, effectiveRundownId],
);
}
@@ -1,24 +1,18 @@
import { useQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { ProjectFile, ProjectFileList, ProjectFileListResponse } from 'ontime-types'; import { ProjectFile, ProjectFileList } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_LIST } from '../api/constants'; import { PROJECT_LIST } from '../api/constants';
import { getProjects } from '../api/db'; import { getProjects } from '../api/db';
const placeholderProjectList: ProjectFileListResponse = { export function useProjectList() {
files: [], const { data, status, refetch } = useSuspenseQuery({
lastLoadedProject: '',
};
function useProjectList() {
const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST, queryKey: PROJECT_LIST,
queryFn: ({ signal }) => getProjects({ signal }), queryFn: ({ signal }) => getProjects({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, staleTime: MILLIS_PER_HOUR,
refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? placeholderProjectList, status, refetch }; return { data, status, refetch };
} }
export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc'; export type ProjectSortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'modified-asc' | 'modified-desc';
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { ProjectRundownsList } from 'ontime-types'; import { ProjectRundownsList } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { PROJECT_RUNDOWNS } from '../api/constants'; import { PROJECT_RUNDOWNS } from '../api/constants';
import { import {
createRundown, createRundown,
@@ -11,24 +11,21 @@ import {
loadRundown, loadRundown,
renameRundown, renameRundown,
} from '../api/rundown'; } from '../api/rundown';
import { ontimeQueryClient } from '../queryClient';
//TODO: make suspends so we don't have to deal with no value all over
/** /**
* Project rundowns * Project rundowns
*/ */
export function useProjectRundowns() { export function useProjectRundowns() {
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({ const { data, status, isError, refetch, isFetching } = useSuspenseQuery<ProjectRundownsList>({
queryKey: PROJECT_RUNDOWNS, queryKey: PROJECT_RUNDOWNS,
queryFn: ({ signal }) => fetchProjectRundownList({ signal }), queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
placeholderData: (previousData, _previousQuery) => previousData, staleTime: MILLIS_PER_HOUR,
refetchInterval: queryRefetchIntervalSlow,
}); });
return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching }; return { data, status, isError, refetch, isFetching };
} }
export function useMutateProjectRundowns() { export function useMutateProjectRundowns() {
const ontimeQueryClient = useQueryClient();
const { mutateAsync: create } = useMutation({ const { mutateAsync: create } = useMutation({
mutationFn: createRundown, mutationFn: createRundown,
onMutate: () => { onMutate: () => {
@@ -1,10 +1,10 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types'; import { EntryId, Maybe, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react'; import { useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants'; import { getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown'; import { fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket'; import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns'; import { useProjectRundowns } from './useProjectRundowns';
@@ -20,43 +20,36 @@ const cachedRundownPlaceholder: Rundown = {
}; };
/** /**
* Normalised rundown data for the currently loaded rundown. * Provides access to a specific rundown by ID.
* * When rundownId is not provided the loaded rundown is provided
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
* independent of the project rundown list. Once the loaded id is known, the
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
*/ */
export default function useRundown() { export function useRundown(rundownId: Maybe<string>) {
const queryClient = useQueryClient(); 'use memo';
const { const {
data: { loaded: loadedRundownId }, data: { loaded: loadedRundownId },
} = useProjectRundowns(); } = useProjectRundowns();
const effectiveRundownId = rundownId !== null ? rundownId : loadedRundownId;
const isLoadedRundown = effectiveRundownId === loadedRundownId;
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY, queryKey: getRundownQueryKey(effectiveRundownId),
queryFn: ({ signal }) => fetchCurrentRundown({ signal }), queryFn: ({ signal }) => fetchRundown(effectiveRundownId, { signal }),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
// Seed the id-keyed cache when fetching via the bootstrap alias return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching, isLoadedRundown };
useEffect(() => {
if (!data || loadedRundownId) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, loadedRundownId, queryClient]);
// Once we have the ID, drop the temporary current cache
useEffect(() => {
if (!loadedRundownId) return;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
} }
export function useRundownWithMetadata() { export function useRundownWithMetadata(rundownId: Maybe<string>) {
const { data, status } = useRundown(); 'use memo';
const { data, status, isLoadedRundown } = useRundown(rundownId);
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null;
const rundownMetadata = getRundownMetadata(data, effectiveSelectedEventId);
return { data, status, rundownMetadata }; return { data, status, rundownMetadata };
} }
@@ -64,8 +57,8 @@ export function useRundownWithMetadata() {
* Provides access to a flat rundown * Provides access to a flat rundown
* built from the order and rundown fields * built from the order and rundown fields
*/ */
export function useFlatRundown() { export function useFlatRundown(rundownId: Maybe<string>) {
const { data, status } = useRundown(); const { data, status } = useRundown(rundownId);
const flatRundown = useMemo(() => { const flatRundown = useMemo(() => {
if (data.revision === -1) { if (data.revision === -1) {
@@ -77,11 +70,13 @@ export function useFlatRundown() {
return { data: flatRundown, rundownId: data.id, status }; return { data: flatRundown, rundownId: data.id, status };
} }
export function useFlatRundownWithMetadata() { export function useFlatRundownWithMetadata(rundownId: Maybe<string>) {
const { data, status } = useRundown(); 'use memo';
const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); const { data, status, isLoadedRundown } = useRundown(rundownId);
const selectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedRundown ? selectedEventId : null;
const rundownWithMetadata = getFlatRundownMetadata(data, effectiveSelectedEventId);
return { data: rundownWithMetadata, status }; return { data: rundownWithMetadata, status };
} }
@@ -92,8 +87,8 @@ export function useFlatRundownWithMetadata() {
* re-filtering on every render. * re-filtering on every render.
* *
*/ */
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) { export function usePartialRundown(rundownId: Maybe<string>, cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata(); const { data, status } = useFlatRundownWithMetadata(rundownId);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return data.filter(cb); return data.filter(cb);
}, [data, cb]); }, [data, cb]);
@@ -103,37 +98,20 @@ export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boo
/** /**
* Hook to get a specific entry by ID from the rundown * Hook to get a specific entry by ID from the rundown
* @deprecated
*/ */
export function useEntry(entryId: EntryId | null): OntimeEntry | null { export function useEntry(rundownId: Maybe<string>, entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useRundown(); const { data: rundown } = useRundown(rundownId);
if (entryId === null) return null; if (entryId === null) return null;
return rundown.entries[entryId] ?? null; return rundown.entries[entryId] ?? null;
} }
export function useRundownAuxData() { export function useRundownAuxData(rundownId: Maybe<string>) {
const { data, status } = useRundown(); const { data, status } = useRundown(rundownId);
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
const { title, id } = data; const { title, id } = data;
return { title, id }; return { title, id };
}, [data]); }, [data]);
return { data: filteredData, status }; return { data: filteredData, status };
} }
/**
* Provides access to a specific rundown by ID.
* When rundownId is null/undefined the query is disabled and returns the placeholder.
*/
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -1,54 +0,0 @@
import { EntryId, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { useRundownById } from './useRundown';
export type RundownSource = {
rundownId: string | null;
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
selectedEventId: EntryId | null;
};
/**
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
*/
export function useScopedRundown(rundownId: string | null): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
return useRundownSource(rundownId, projectRundowns.loaded || null);
}
/**
* Loaded-rundown source for views that must follow the active runtime rundown.
*/
export function useLoadedRundownSource(): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
const loadedRundownId = projectRundowns.loaded || null;
return useRundownSource(loadedRundownId, loadedRundownId);
}
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundownId,
rundown,
flatRundown,
status,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
);
}
@@ -0,0 +1,41 @@
import { MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress';
import { usePlayback } from './useSocket';
/**
* Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
const playback = usePlayback();
const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, at: performance.now() });
const [, setTick] = useState(0);
// there is only something to animate while a running timer is counting down towards 0
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
// re-anchor to the authoritative value whenever the server pushes a new timer update
useEffect(() => {
baseline.current = { current, at: performance.now() };
}, [current, duration, playback]);
// while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => {
if (!shouldAnimate) {
return;
}
let frame = requestAnimationFrame(function tick() {
setTick((value) => value + 1);
frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [shouldAnimate]);
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running
const anchored = baseline.current.current;
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
return getProgress(value, duration);
}
+13 -7
View File
@@ -1,7 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
InsertOptions,
MaybeString, MaybeString,
OntimeDelay, OntimeDelay,
OntimeEntry, OntimeEntry,
@@ -60,9 +59,15 @@ export type EventOptions = Partial<{
before: MaybeString; before: MaybeString;
// options of entries of type OntimeEvent // options of entries of type OntimeEvent
linkPrevious: boolean; linkPrevious: boolean;
/** the timing reference for the new entry, usually the same as after */
lastEventId: MaybeString; lastEventId: MaybeString;
}>; }>;
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/** /**
* Gather utilities for actions on entries in the loaded rundown. * Gather utilities for actions on entries in the loaded rundown.
*/ */
@@ -120,7 +125,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* @private * @private
*/ */
const { mutateAsync: addEntryMutation } = useMutation({ const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry), mutationFn: ([rundownId, entry]: [string, PatchWithId & ClientInsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => { onMutate: async ([_rundownId, entry]) => {
const queryKey = resolveCurrentRundownQueryKey(); const queryKey = resolveCurrentRundownQueryKey();
await queryClient.cancelQueries({ queryKey }); await queryClient.cancelQueries({ queryKey });
@@ -149,8 +154,9 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
addToRundown( addToRundown(
newRundown, newRundown,
optimisticEntry, optimisticEntry,
afterId,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null, parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
afterId,
entry.before ?? null,
); );
queryClient.setQueryData<Rundown>(queryKey, newRundown); queryClient.setQueryData<Rundown>(queryKey, newRundown);
@@ -193,7 +199,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
throw new Error('Rundown not initialised'); throw new Error('Rundown not initialised');
} }
const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() }; const newEntry: PatchWithId & ClientInsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types // handle adding options that concern all event types
if (options?.after) { if (options?.after) {
@@ -205,8 +211,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) { if (isOntimeEvent(newEntry)) {
// last event id is the timing reference of the previous OntimeEvent in rundown
if (options?.lastEventId) { if (options?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const previousEvent = rundownData.entries[options?.lastEventId]; const previousEvent = rundownData.entries[options?.lastEventId];
if (isOntimeEvent(previousEvent)) { if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd; newEntry.timeStart = previousEvent.timeEnd;
@@ -284,7 +290,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* Clone an entry * Clone an entry
*/ */
const clone = useCallback( const clone = useCallback(
async (entryId: EntryId, options?: InsertOptions) => { async (entryId: EntryId, options?: ClientInsertOptions) => {
try { try {
const rundownId = getCurrentRundownData()?.id; const rundownId = getCurrentRundownData()?.id;
if (!rundownId) { if (!rundownId) {
@@ -1057,7 +1063,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
/** /**
* Utility to create an optimistic entry for immediate cache insertion * Utility to create an optimistic entry for immediate cache insertion
*/ */
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry { function createOptimisticEntry(payload: PatchWithId & ClientInsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload; const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id; const id = entryData.id;
let parent: EntryId | null = null; let parent: EntryId | null = null;
+11
View File
@@ -139,6 +139,17 @@ export const setEventPlayback = {
pause: () => sendSocket('pause', undefined), pause: () => sendSocket('pause', undefined),
}; };
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
phase: state.timer.phase,
addedTime: state.timer.addedTime,
secondaryTimer: state.timer.secondaryTimer,
current: state.timer.current,
expectedFinish: state.timer.expectedFinish,
startedAt: state.timer.startedAt,
isCountToEnd: state.eventNow?.countToEnd ?? false,
}));
export const useTimer = createSelector((state: RuntimeStore) => ({ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer, ...state.timer,
})); }));
@@ -0,0 +1,87 @@
import { hasCustomParams, stripReservedParams, useSavedViewParams } from '../savedViewParams';
describe('savedViewParams store', () => {
beforeEach(() => {
useSavedViewParams.getState().clearAll();
});
it('saves and restores params per view', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=true');
expect(useSavedViewParams.getState().params.backstage).toBe('showProgress=false');
});
it('overwrites the saved params for a view on subsequent saves', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', 'hideSeconds=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=false');
});
it('does not store an entry for an empty search string', () => {
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('removes a previously saved entry when saved with an empty search string', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('ignores empty view keys', () => {
useSavedViewParams.getState().save('', 'hideSeconds=true');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('clears the saved params for a single view without touching others', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('is a no-op when clearing a view without saved params', () => {
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('clears all saved params', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().clearAll();
expect(useSavedViewParams.getState().params).toEqual({});
});
});
describe('stripReservedParams', () => {
it('removes reserved auth/preset params while keeping view customisation', () => {
expect(stripReservedParams('hideSeconds=true&token=abc&n=1&alias=my')).toBe('hideSeconds=true');
});
it('returns an empty string when only reserved params are present', () => {
expect(stripReservedParams('token=abc&n=1&alias=my')).toBe('');
});
});
describe('hasCustomParams', () => {
it('is true when a non-reserved param is present', () => {
expect(hasCustomParams(new URLSearchParams('hideSeconds=true&token=abc'))).toBe(true);
});
it('is false when only reserved params are present', () => {
expect(hasCustomParams(new URLSearchParams('token=abc&n=1&alias=my'))).toBe(false);
});
it('is false when there are no params', () => {
expect(hasCustomParams(new URLSearchParams(''))).toBe(false);
});
});
@@ -0,0 +1,62 @@
import { create } from 'zustand';
// params that are auth/preset markers, not user view customisation (see common/utils/urlPresets.ts)
export const reservedParams = new Set(['token', 'n', 'alias']);
interface SavedViewParamsStore {
params: Record<string, string>; // view key (e.g. "timer") -> search string without leading "?"
save: (view: string, search: string) => void;
clear: (view: string) => void;
clearAll: () => void;
}
/**
* Remembers the last view parameters used for each view so they can be
* restored when the user navigates back to that view through the menu.
* In-memory only: persists across SPA navigation, resets on a full page reload.
*/
export const useSavedViewParams = create<SavedViewParamsStore>((set) => ({
params: {},
save: (view, search) =>
set((state) => {
// ignore empty view keys and do not store empty entries, so the
// "saved changes" indicator only reflects genuine customisation
if (!view) return state;
const params = { ...state.params };
if (search) {
params[view] = search;
} else {
delete params[view];
}
return { params };
}),
clear: (view) =>
set((state) => {
if (!state.params[view]) return state;
const params = { ...state.params };
delete params[view];
return { params };
}),
clearAll: () => set({ params: {} }),
}));
/**
* Removes reserved (auth/preset) params from a search string, keeping only
* genuine view customisation.
*/
export function stripReservedParams(search: string): string {
const sp = new URLSearchParams(search);
reservedParams.forEach((key) => sp.delete(key));
return sp.toString();
}
/**
* Whether the search params contain any genuine view customisation,
* ignoring reserved params.
*/
export function hasCustomParams(searchParams: URLSearchParams): boolean {
for (const key of searchParams.keys()) {
if (!reservedParams.has(key)) return true;
}
return false;
}
@@ -1,4 +1,4 @@
import { linkToOtherHost } from '../linkUtils'; import { hostToBaseUrl, linkToOtherHost } from '../linkUtils';
describe('linkToOTherHost', () => { describe('linkToOTherHost', () => {
it('should handle electron links', () => { it('should handle electron links', () => {
@@ -22,3 +22,18 @@ describe('linkToOTherHost', () => {
expect(destination).toBe('https://app.getontime.no/user-hash/path'); expect(destination).toBe('https://app.getontime.no/user-hash/path');
}); });
}); });
describe('hostToBaseUrl', () => {
it('uses http when the page is plain http', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'http://localhost:4001')).toBe('http://192.168.10.166:4001');
});
it('keeps https when the page reaches the server over TLS on the same port', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://localhost:4001')).toBe('https://192.168.10.166:4001');
});
it('falls back to http when the page is https on a different port (TLS proxy)', () => {
// the raw server port does not speak TLS, an https link to it would fail
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://ontime.example.com')).toBe('http://192.168.10.166:4001');
});
});
@@ -90,6 +90,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 0, eventIndex: 0,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -107,6 +108,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, // UI indexes are 1 based eventIndex: 1, // UI indexes are 1 based
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -124,6 +126,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, eventIndex: 1,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: true,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -140,6 +143,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -156,6 +160,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: true, isPast: true,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -172,6 +177,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 3, eventIndex: 3,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: true, isLoaded: true,
@@ -188,6 +194,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 4, eventIndex: 4,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: true,
isLoaded: false, isLoaded: false,
@@ -204,6 +211,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 5, eventIndex: 5,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 7, totalGap: 7,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -256,6 +264,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 0, eventIndex: 0,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -273,6 +282,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 1, eventIndex: 1,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -290,6 +300,7 @@ describe('initRundownMetadata()', () => {
eventIndex: 2, eventIndex: 2,
isPast: false, isPast: false,
isNextDay: false, isNextDay: false,
isParentToLoaded: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: false, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { removeFileExtension } from '../uploadUtils';
describe('removeFileExtension()', () => {
it('removes a trailing extension', () => {
expect(removeFileExtension('show.xlsx')).toBe('show');
});
it('only removes the last extension', () => {
expect(removeFileExtension('my.show.xlsx')).toBe('my.show');
});
it('returns the name unchanged when there is no extension', () => {
expect(removeFileExtension('rundown')).toBe('rundown');
});
it('does not treat a leading dot as an extension', () => {
expect(removeFileExtension('.gitignore')).toBe('.gitignore');
});
it('handles an empty string', () => {
expect(removeFileExtension('')).toBe('');
});
});
+20 -1
View File
@@ -37,7 +37,12 @@ export function handleLinks(
event?.preventDefault(); event?.preventDefault();
const destination = new URL(externalServerUrl); const destination = new URL(externalServerUrl);
destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location; // split off any query string so it is not encoded into the pathname
const [pathname, search] = location.split('?');
destination.pathname = externalBaseURI ? `${externalBaseURI}/${pathname}` : pathname;
if (search) {
destination.search = search;
}
openLink(destination.toString()); openLink(destination.toString());
} }
@@ -54,3 +59,17 @@ export function linkToOtherHost(
} }
return destination.toString(); return destination.toString();
} }
/**
* Builds a base URL for reaching the Ontime server on a given network interface.
* The Ontime server itself only speaks HTTP, so we only keep https when the page
* already reaches the server over TLS on that same port (eg. a proxy in front of it);
* any other explicit server port gets http.
* externalServerUrl is used for testing
*/
export function hostToBaseUrl(host: string, port: number, externalServerUrl: string = serverURL): string {
const page = new URL(externalServerUrl);
const pagePort = page.port === '' ? (page.protocol === 'https:' ? 443 : 80) : Number(page.port);
const scheme = page.protocol === 'https:' && pagePort === port ? 'https' : 'http';
return `${scheme}://${host}:${port}`;
}
@@ -27,6 +27,7 @@ export type RundownMetadata = {
groupColour: string | undefined; groupColour: string | undefined;
groupEntries: number | undefined; groupEntries: number | undefined;
isFirstAfterGroup: boolean; isFirstAfterGroup: boolean;
isParentToLoaded: boolean; // if the group contains the loaded event
}; };
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata; export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
@@ -94,6 +95,7 @@ export function initRundownMetadata(selectedEventId: MaybeString) {
groupColour: undefined, groupColour: undefined,
groupEntries: undefined, groupEntries: undefined,
isFirstAfterGroup: false, isFirstAfterGroup: false,
isParentToLoaded: false,
}; };
function process(entry: OntimeEntry): Readonly<RundownMetadata> { function process(entry: OntimeEntry): Readonly<RundownMetadata> {
@@ -117,6 +119,7 @@ function processEntry(
// initialise data to be overridden below // initialise data to be overridden below
processedData.isNextDay = false; processedData.isNextDay = false;
processedData.isLoaded = false; processedData.isLoaded = false;
processedData.isParentToLoaded = false;
processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
processedData.thisId = entry.id; // we reassign thisId processedData.thisId = entry.id; // we reassign thisId
@@ -132,6 +135,7 @@ function processEntry(
processedData.groupId = entry.id; processedData.groupId = entry.id;
processedData.groupColour = entry.colour; processedData.groupColour = entry.colour;
processedData.groupEntries = entry.entries.length; processedData.groupEntries = entry.entries.length;
processedData.isParentToLoaded = selectedEventId ? entry.entries.includes(selectedEventId) : false;
} else { } else {
// for delays and groups, we insert the group metadata // for delays and groups, we insert the group metadata
if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) { if ((entry as OntimeEvent | OntimeDelay | OntimeMilestone).parent !== processedData.groupId) {
+4 -8
View File
@@ -14,7 +14,6 @@ import {
APP_SETTINGS, APP_SETTINGS,
CLIENT_LIST, CLIENT_LIST,
CSS_OVERRIDE, CSS_OVERRIDE,
CURRENT_RUNDOWN_QUERY_KEY,
CUSTOM_FIELDS, CUSTOM_FIELDS,
PROJECT_DATA, PROJECT_DATA,
REPORT, REPORT,
@@ -25,6 +24,7 @@ import {
VIEW_SETTINGS, VIEW_SETTINGS,
getRundownQueryKey, getRundownQueryKey,
PROJECT_RUNDOWNS, PROJECT_RUNDOWNS,
PROJECT_LIST,
} from '../api/constants'; } from '../api/constants';
import { invalidateAllCaches } from '../api/utils'; import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
@@ -216,6 +216,9 @@ export const connectSocket = () => {
case RefetchKey.ProjectRundowns: case RefetchKey.ProjectRundowns:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS }); ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
break; break;
case RefetchKey.ProjectFiles:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_LIST });
break;
default: { default: {
target satisfies never; target satisfies never;
break; break;
@@ -238,7 +241,6 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
if (!rundownId) { if (!rundownId) {
// we omit rundownId to signify invalidate all rundowns // we omit rundownId to signify invalidate all rundowns
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
return; return;
} }
@@ -252,12 +254,6 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
} }
ontimeQueryClient.invalidateQueries({ queryKey, exact: true }); ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
// keep current alias in sync with the ID-based cache
const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded;
if (!loadedRundownId || loadedRundownId === rundownId) {
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}
} }
export function sendSocket<T extends MessageTag | ApiActionTag>( export function sendSocket<T extends MessageTag | ApiActionTag>(
+9 -4
View File
@@ -3,7 +3,9 @@ import {
MILLIS_PER_HOUR, MILLIS_PER_HOUR,
MILLIS_PER_MINUTE, MILLIS_PER_MINUTE,
MILLIS_PER_SECOND, MILLIS_PER_SECOND,
dayInMs,
formatFromMillis, formatFromMillis,
getExpectedEnd,
getExpectedStart, getExpectedStart,
} from 'ontime-utils'; } from 'ontime-utils';
@@ -29,6 +31,11 @@ export function nowInMillis(): number {
return elapsed; return elapsed;
} }
export function normaliseWallClock(time: number): number {
const timeOfDay = time % dayInMs;
return timeOfDay < 0 ? timeOfDay + dayInMs : timeOfDay;
}
/** /**
* @description Resolves format from url and store * @description Resolves format from url and store
* @return {string|null} A format string like "hh:mm:ss a" or null * @return {string|null} A format string like "hh:mm:ss a" or null
@@ -180,15 +187,13 @@ export function getExpectedTimesFromExtendedEvent(
...state, ...state,
}, },
); );
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
const plannedEnd = event.timeStart + event.duration + event.delay; const plannedEnd = event.timeStart + event.duration + event.delay;
return { return {
expectedStart, expectedStart,
timeToStart: expectedStart - state.clock, timeToStart: expectedStart - state.clock,
expectedEnd: event.countToEnd expectedEnd,
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
plannedEnd, plannedEnd,
}; };
} }
@@ -38,6 +38,18 @@ export function validateProjectFile(file: File) {
} }
} }
/**
* Removes a trailing file extension from a file name (e.g. "show.xlsx" -> "show")
* A leading dot (dotfiles like ".gitignore") is not treated as an extension
*/
export function removeFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0) {
return fileName;
}
return fileName.slice(0, lastDot);
}
export function isExcelFile(file: File | null) { export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx'); return file?.name.endsWith('.xlsx');
} }
@@ -38,7 +38,7 @@ $inner-padding: 1rem;
.section { .section {
position: relative; position: relative;
margin-top: 2rem; margin-top: 2rem;
max-width: 850px; max-width: 1024px;
} }
.indent { .indent {
@@ -293,8 +293,9 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<h3>Outputs</h3> <h3>Outputs</h3>
<Info> <Info>
Automation outputs can be used to send data from Ontime to external software <br /> Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself. or to change properties of Ontime itself. <br /> <br />
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink> Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info> </Info>
{fieldOutputs.map((output, index) => { {fieldOutputs.map((output, index) => {
@@ -341,12 +342,17 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</label> </label>
<label> <label>
Address Address
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' /> <TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error> <Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label> </label>
<label> <label>
Arguments Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' /> <TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error> <Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label> </label>
<div> <div>
@@ -376,7 +382,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div className={style.httpSection}> <div className={style.httpSection}>
<label> <label>
Target URL Target URL
<Input <TemplateInput
{...register(`outputs.${index}.url`, { {...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' }, required: { value: true, message: 'Required field' },
pattern: { pattern: {
@@ -384,6 +390,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
message: 'HTTP messages should target http:// or https://', message: 'HTTP messages should target http:// or https://',
}, },
})} })}
value={output.url}
fluid fluid
placeholder='http://127.0.0.1/start/1' placeholder='http://127.0.0.1/start/1'
/> />
@@ -5,6 +5,7 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
@@ -71,8 +72,8 @@ export default function OntimeActionForm({
{ value: 'playback-pause', label: 'Playback: pause' }, { value: 'playback-pause', label: 'Playback: pause' },
{ value: 'playback-roll', label: 'Playback: roll' }, { value: 'playback-roll', label: 'Playback: roll' },
{ value: 'message-set', label: 'Primary Message: set' }, { value: 'message-set', label: 'Primary Message' },
{ value: 'message-secondary', label: 'Secondary Message: source' }, { value: 'message-secondary', label: 'Secondary Message' },
]} ]}
/> />
<Panel.Error>{rowErrors?.action?.message}</Panel.Error> <Panel.Error>{rowErrors?.action?.message}</Panel.Error>
@@ -96,7 +97,12 @@ export default function OntimeActionForm({
<> <>
<label> <label>
Text (leave empty for no change) Text (leave empty for no change)
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' /> <TemplateInput
{...register(`outputs.${index}.text`)}
value={watch(`outputs.${index}.text`) ?? ''}
fluid
placeholder='eg: Timer is finished'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error> <Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label> </label>
<label> <label>
@@ -120,31 +126,48 @@ export default function OntimeActionForm({
)} )}
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<label> <>
Timer secondary source <label>
<Select<SecondarySource | 'null' | null> Text (leave empty for no change)
onValueChange={(value) => { <TemplateInput
// null -> no selection {...register(`outputs.${index}.text`)}
if (value === null) return; value={watch(`outputs.${index}.text`) ?? ''}
// 'null' -> clear the secondary source fluid
if (value === 'null') { placeholder='eg: Next up: keynote'
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true }); />
return; <Panel.Error>{rowErrors?.text?.message}</Panel.Error>
} </label>
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true }); <label>
}} Timer secondary source
value={watch(`outputs.${index}.secondarySource`)} <Select<SecondarySource | 'no-change' | 'null' | null>
options={[ onValueChange={(value) => {
{ value: null, label: 'Select secondary source' }, // null -> no selection
{ value: 'aux1', label: 'Auxiliary timer 1' }, if (value === null) return;
{ value: 'aux2', label: 'Auxiliary timer 2' }, // no-change -> leave the current secondary source untouched
{ value: 'aux3', label: 'Auxiliary timer 3' }, if (value === 'no-change') {
{ value: 'secondary', label: 'Secondary' }, setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true });
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source return;
]} }
/> // 'null' -> clear the secondary source
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error> if (value === 'null') {
</label> setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
options={[
{ value: 'no-change', label: 'No change' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
</>
)} )}
<div className={style.test}>{children}</div> <div className={style.test}>{children}</div>
@@ -79,7 +79,7 @@ export default function TriggersList(props: TriggersListProps) {
<tbody> <tbody>
{!showForm && triggers.length === 0 && ( {!showForm && triggers.length === 0 && (
<Panel.TableEmpty <Panel.TableEmpty
label='Create an automation to attach triggers to' label='Create a trigger to run an automation'
handleClick={canAdd ? () => setShowForm(true) : undefined} handleClick={canAdd ? () => setShowForm(true) : undefined}
/> />
)} )}
@@ -1,29 +1,67 @@
.wrapper { .inputShell {
position: relative; position: relative;
} }
.suggestions { .fluid {
background: $gray-1250;
color: $ui-white;
position: absolute;
top: 100%;
left: 0;
width: 100%; width: 100%;
margin: 0; }
z-index: $zindex-floating;
padding-block: 0.25rem;
max-height: 200px; .input {
overflow-y: auto; padding-right: 2rem;
}
.expandButton {
position: absolute;
top: 50%;
right: 0.25rem;
transform: translateY(-50%);
}
.positioner {
outline: 0;
}
.popup {
box-sizing: border-box;
min-width: var(--anchor-width);
max-width: var(--available-width);
border: 1px solid $gray-1000;
border-radius: $component-border-radius-md;
background: $gray-1250;
color: $label-gray; color: $label-gray;
}
li { .list {
padding: 0.25rem; box-sizing: border-box;
} max-height: min(20rem, var(--available-height));
overflow-y: auto;
overscroll-behavior: contain;
padding-block: 0.25rem;
outline: 0;
}
li:hover { .item {
box-sizing: border-box;
padding: 0.25rem 0.5rem;
outline: 0;
cursor: default;
user-select: none;
overflow-wrap: anywhere;
&[data-highlighted] {
color: $ui-white; color: $ui-white;
background: $blue-700; background: $blue-700;
} }
} }
.expandedEditor {
min-height: min(18rem, 45vh);
font-family: monospace;
line-height: 1.4;
}
.footerHint {
margin-right: auto;
color: $label-gray;
font-size: calc(1rem - 2px);
}
@@ -1,69 +1,258 @@
import { mergeRefs, useClickOutside } from '@mantine/hooks'; import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import { forwardRef, useMemo, useState } from 'react'; import type { ChangeEvent, ReactNode, Ref } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoExpandOutline } from 'react-icons/io5';
import Input, { type InputProps } from '../../../../../common/components/input/input/Input'; import Button from '../../../../../common/components/buttons/Button';
import IconButton from '../../../../../common/components/buttons/IconButton';
import { type InputProps } from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils'; import { cx } from '../../../../../common/utils/styleUtils';
import { makeAutoCompleteList } from './templateInput.utils';
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
import style from './TemplateInput.module.scss'; import style from './TemplateInput.module.scss';
interface TemplateInputProps extends InputProps {} interface TemplateInputProps extends Omit<InputProps, 'value'> {
ref?: Ref<HTMLInputElement>;
value?: string;
}
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) { interface TemplateEditorModalProps {
const { value, onChange, ...rest } = props; autocompleteList: string[];
isOpen: boolean;
onClose: () => void;
onSave: (value: string) => void;
value: string;
}
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
interface TemplateAutocompleteRootProps {
autocomplete: TemplateAutocompleteState;
children: ReactNode;
value: string;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
ref.current = value;
}
}
function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
onChange?.({
target: { name, value },
currentTarget: { name, value },
} as ChangeEvent<HTMLInputElement>);
}
export default function TemplateInput({
className,
disabled,
fluid,
height = 'medium',
onChange,
readOnly,
ref,
value,
variant = 'subtle',
...rest
}: TemplateInputProps) {
const { data } = useCustomFields(); const { data } = useCustomFields();
const localRef = useClickOutside(() => setShowSuggestions(false)); const inputRef = useRef<HTMLInputElement | null>(null);
const [inputValue, setInputValue] = useState(value || '');
const [isExpanded, setIsExpanded] = useState(false);
const autocompleteList = useMemo(() => { const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data); return makeAutoCompleteList(data);
}, [data]); }, [data]);
const [inputValue, setInputValue] = useState(value || ''); const updateInputValue = useCallback(
const [suggestions, setSuggestions] = useState<string[]>([]); (nextValue: string) => {
const [showSuggestions, setShowSuggestions] = useState(false); setInputValue(nextValue);
emitInputChange(rest.name, nextValue, onChange);
},
[onChange, rest.name],
);
const updateSuggestions = (value: string) => { const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue);
const template = selectFromLastTemplate(value); const { setCursorForValue } = autocomplete;
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
// Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
useEffect(() => {
const nextValue = value || '';
setInputValue(nextValue);
setCursorForValue(nextValue, nextValue.length);
}, [setCursorForValue, value]);
const setInputRef = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
assignRef(ref, element);
},
[ref],
);
const openExpandedEditor = () => {
autocomplete.setShowSuggestions(false);
setIsExpanded(true);
}; };
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { const closeExpandedEditor = () => {
setInputValue(event.target.value); setIsExpanded(false);
if (event.target.value.endsWith('{')) {
setShowSuggestions(true);
setSuggestions(updateSuggestions(event.target.value));
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
setShowSuggestions(false);
} else if (showSuggestions) {
setSuggestions(updateSuggestions(event.target.value));
}
onChange?.(event);
}; };
const handleSuggestion = (value: string) => { const saveExpandedEditor = (nextValue: string) => {
setInputValue((prev) => { updateInputValue(nextValue);
const remaining = matchRemaining(prev as string, value); autocomplete.setCursorForValue(nextValue, nextValue.length);
return prev + remaining; setIsExpanded(false);
});
setShowSuggestions(false);
}; };
return ( return (
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}> <>
<Input value={inputValue} {...rest} onChange={handleInputChange} fluid /> <TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
{showSuggestions && suggestions.length > 0 && ( <div className={cx([style.inputShell, fluid && style.fluid])}>
<ul className={style.suggestions}> <BaseAutocomplete.Input
{suggestions.map((suggestion) => ( ref={setInputRef}
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}> className={cx([
{suggestion} inputStyle.input,
</li> inputStyle[variant],
))} inputStyle[height],
</ul> fluid && inputStyle.fluid,
)} style.input,
</div> className,
])}
{...rest}
disabled={disabled}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
readOnly={readOnly}
/>
<IconButton
aria-label='Expand template editor'
className={style.expandButton}
disabled={disabled || readOnly}
onClick={openExpandedEditor}
size='small'
title='Expand template editor'
variant='ghosted-white'
>
<IoExpandOutline />
</IconButton>
</div>
</TemplateAutocompleteRoot>
<TemplateEditorModal
autocompleteList={autocompleteList}
isOpen={isExpanded}
onClose={closeExpandedEditor}
onSave={saveExpandedEditor}
value={inputValue}
/>
</>
); );
}); }
export default TemplateInput; function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
return (
<BaseAutocomplete.Root
items={autocomplete.suggestions}
autoHighlight
highlightItemOnHover
mode='none'
open={autocomplete.open}
value={value}
onOpenChange={autocomplete.setShowSuggestions}
onValueChange={autocomplete.handleValueChange}
>
{children}
<TemplateSuggestionPopup />
</BaseAutocomplete.Root>
);
}
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
const expandedInputRef = useRef<HTMLTextAreaElement | null>(null);
const [draftValue, setDraftValue] = useState(value);
const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
const { setShowSuggestions } = autocomplete;
// Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
useEffect(() => {
if (!isOpen) {
return;
}
setDraftValue(value);
setShowSuggestions(false);
}, [isOpen, setShowSuggestions, value]);
const handleClose = () => {
setShowSuggestions(false);
onClose();
};
const handleSave = () => {
setShowSuggestions(false);
onSave(draftValue);
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
showBackdrop
showCloseButton
title='Edit template'
bodyElements={
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}>
<BaseAutocomplete.Input
autoFocus
className={style.expandedEditor}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
/>
</TemplateAutocompleteRoot>
}
footerElements={
<>
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSave} variant='primary'>
Save
</Button>
</>
}
/>
);
}
function TemplateSuggestionPopup() {
return (
<BaseAutocomplete.Portal>
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
<BaseAutocomplete.Popup className={style.popup}>
<BaseAutocomplete.List className={style.list}>
<BaseAutocomplete.Collection>
{(suggestion: string) => (
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
{suggestion}
</BaseAutocomplete.Item>
)}
</BaseAutocomplete.Collection>
</BaseAutocomplete.List>
</BaseAutocomplete.Popup>
</BaseAutocomplete.Positioner>
</BaseAutocomplete.Portal>
);
}
@@ -1,4 +1,4 @@
import { matchRemaining } from '../templateInput.utils'; import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils';
describe('matchRemaining()', () => { describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => { it('should return a partial string needed for autocomplete', () => {
@@ -15,3 +15,54 @@ describe('matchRemaining()', () => {
expect(matchRemaining('test', 'test')).toBe(''); expect(matchRemaining('test', 'test')).toBe('');
}); });
}); });
describe('selectActiveTemplate()', () => {
it('returns the last unclosed template fragment', () => {
expect(selectActiveTemplate('send {{event')).toBe('{{event');
expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
});
it('ignores single braces and closed templates', () => {
expect(selectActiveTemplate('send {')).toBe('');
expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
});
it('only considers templates before the cursor', () => {
expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
});
it('selects a partial template when the cursor is inside a completed template', () => {
expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
});
});
describe('completeTemplateAtCursor()', () => {
it('completes the active template before the cursor', () => {
expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
value: 'send {{timer.current}} after',
cursorIndex: 22,
});
});
it('preserves text before and after the cursor', () => {
expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
value: 'before {{eventNow.title}} after',
cursorIndex: 25,
});
});
it('preserves a following template when completing between templates', () => {
expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
cursorIndex: 31,
});
});
it('replaces the whole template when the cursor is inside a completed template', () => {
expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
value: 'before {{timer.duration}} after',
cursorIndex: 25,
});
});
});
@@ -54,6 +54,16 @@ const eventStaticPropertiesNext = [
'{{eventNext.delay}}', '{{eventNext.delay}}',
]; ];
const groupStaticPropertiesNow = [
'{{groupNow.id}}',
'{{groupNow.title}}',
'{{groupNow.note}}',
'{{groupNow.colour}}',
'{{groupNow.timeStart}}',
'{{groupNow.timeEnd}}',
'{{groupNow.duration}}',
];
const staticAuxProperties = (index: 1 | 2 | 3) => [ const staticAuxProperties = (index: 1 | 2 | 3) => [
`{{auxtimer${index}.current}}`, `{{auxtimer${index}.current}}`,
`{{auxtimer${index}.duration}}`, `{{auxtimer${index}.duration}}`,
@@ -75,12 +85,19 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`), ...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext, ...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`), ...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
...groupStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
...staticAuxProperties(1), ...staticAuxProperties(1),
...staticAuxProperties(2), ...staticAuxProperties(2),
...staticAuxProperties(3), ...staticAuxProperties(3),
]; ];
} }
interface TemplateCompletion {
cursorIndex: number;
value: string;
}
/** /**
* Returns the partial string b needed to autocomplete string a * Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st' * @example matchRemaining('te', 'test') -> 'st'
@@ -111,13 +128,54 @@ export function matchRemaining(a: string, b: string) {
return ''; return '';
} }
/** function getActiveTemplateRange(text: string, cursorIndex = text.length) {
* Selects the last starting template in a string const textBeforeCursor = text.slice(0, cursorIndex);
*/ const start = textBeforeCursor.lastIndexOf('{{');
export function selectFromLastTemplate(text: string) { if (start === -1) {
const lastBraceIndex = text.lastIndexOf('{{'); return null;
if (lastBraceIndex !== -1) {
return text.slice(lastBraceIndex);
} }
return '';
const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
if (closeBeforeCursor > start) {
return null;
}
const closeAfterStart = text.indexOf('}}', start);
const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
return {
end,
start,
template: text.slice(start, cursorIndex),
};
}
/**
* Selects the last unclosed starting template before the cursor.
*/
export function selectActiveTemplate(text: string, cursorIndex = text.length) {
return getActiveTemplateRange(text, cursorIndex)?.template ?? '';
}
/**
* Replaces the active template fragment before the cursor with the selected suggestion.
*/
export function completeTemplateAtCursor(
text: string,
suggestion: string,
cursorIndex = text.length,
): TemplateCompletion {
const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
if (!activeTemplateRange) {
const value = text + matchRemaining(text, suggestion);
return { value, cursorIndex: value.length };
}
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
return {
value,
cursorIndex: activeTemplateRange.start + suggestion.length,
};
} }
@@ -0,0 +1,72 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import { useCallback, useMemo, useState, type RefObject } from 'react';
import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
function getCursorIndex(element: TemplateElement | null, fallback: string) {
return element?.selectionStart ?? fallback.length;
}
function focusCursor(element: TemplateElement | null, cursorIndex: number) {
requestAnimationFrame(() => {
element?.focus();
element?.setSelectionRange(cursorIndex, cursorIndex);
});
}
export function useTemplateAutocomplete<T extends TemplateElement>(
value: string,
autocompleteList: string[],
elementRef: RefObject<T | null>,
onValueChange: (value: string) => void,
) {
const [showSuggestions, setShowSuggestions] = useState(false);
const [cursor, setCursor] = useState(value.length);
const activeTemplate = selectActiveTemplate(value, cursor);
const suggestions = useMemo(() => {
if (!activeTemplate) {
return [];
}
return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
}, [activeTemplate, autocompleteList]);
const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
setCursor(cursorIndex);
setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
}, []);
const updateCursor = useCallback(() => {
const cursorIndex = getCursorIndex(elementRef.current, value);
setCursorForValue(value, cursorIndex);
}, [elementRef, setCursorForValue, value]);
const handleValueChange = useCallback(
(nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
if (eventDetails.reason === 'item-press') {
eventDetails.cancel();
const completed = completeTemplateAtCursor(value, nextValue, cursor);
setCursorForValue(completed.value, completed.cursorIndex);
onValueChange(completed.value);
focusCursor(elementRef.current, completed.cursorIndex);
return;
}
const cursorIndex = getCursorIndex(elementRef.current, nextValue);
setCursorForValue(nextValue, cursorIndex);
onValueChange(nextValue);
},
[cursor, elementRef, onValueChange, setCursorForValue, value],
);
return {
handleValueChange,
open: showSuggestions && suggestions.length > 0,
setCursorForValue,
setShowSuggestions,
suggestions,
updateCursor,
};
}
@@ -5,7 +5,7 @@ import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils'; import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import useReport from '../../../../common/hooks-query/useReport'; import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown'; import { useRundown } from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time'; import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -15,7 +15,7 @@ import style from './ReportSettings.module.scss';
export default function ReportSettings() { export default function ReportSettings() {
const { data: reportData } = useReport(); const { data: reportData } = useReport();
const { data } = useRundown(); const { data } = useRundown(null);
const clearReport = async () => await deleteAllReport(); const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => { const downloadCSV = (combinedReport: CombinedReport[]) => {
@@ -1,5 +1,5 @@
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { useState } from 'react'; import { Suspense, useState } from 'react';
import { import {
IoAdd, IoAdd,
IoDocumentOutline, IoDocumentOutline,
@@ -18,9 +18,9 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog'; import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useDirectLinkToBackgroundEdit } from '../../../../common/context/RundownSelectionContext';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm'; import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm'; import { ManageRundownForm } from './ManageRundownForm';
@@ -28,6 +28,20 @@ import { ManageRundownForm } from './ManageRundownForm';
import style from './ManagePanel.module.scss'; import style from './ManagePanel.module.scss';
export default function ManageRundowns() { export default function ManageRundowns() {
return (
<Suspense
fallback={
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
}
>
<ManageRundownsSuspense />
</Suspense>
);
}
function ManageRundownsSuspense() {
const { data } = useProjectRundowns(); const { data } = useProjectRundowns();
const { duplicate, remove, load, rename } = useMutateProjectRundowns(); const { duplicate, remove, load, rename } = useMutateProjectRundowns();
const [isOpenDelete, deleteHandlers] = useDisclosure(); const [isOpenDelete, deleteHandlers] = useDisclosure();
@@ -25,6 +25,13 @@
margin-top: 1rem; margin-top: 1rem;
} }
.finishActions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.sourceGrid { .sourceGrid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1,4 +1,6 @@
import type { import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse, SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata, SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions, SpreadsheetWorksheetOptions,
@@ -6,6 +8,7 @@ import type {
import { getErrorMessage, ImportMap } from 'ontime-utils'; import { getErrorMessage, ImportMap } from 'ontime-utils';
import { ChangeEvent, useCallback, useRef, useState } from 'react'; import { ChangeEvent, useCallback, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5'; import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import { import {
getWorksheetMetadata as getExcelWorksheetMetadata, getWorksheetMetadata as getExcelWorksheetMetadata,
@@ -21,8 +24,8 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info'; import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal'; import Modal from '../../../../../common/components/modal/Modal';
import useRundown from '../../../../../common/hooks-query/useRundown'; import { useRundown } from '../../../../../common/hooks-query/useRundown';
import { validateExcelImport } from '../../../../../common/utils/uploadUtils'; import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup'; import GSheetSetup from './GSheetSetup';
import SheetImportEditor from './sheet-import/SheetImportEditor'; import SheetImportEditor from './sheet-import/SheetImportEditor';
@@ -35,6 +38,7 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
type ActiveSource = type ActiveSource =
| { | {
kind: 'excel'; kind: 'excel';
fileName: string;
worksheetNames: string[]; worksheetNames: string[];
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null; initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
closedByUser: boolean; closedByUser: boolean;
@@ -53,9 +57,11 @@ export default function SourcesPanel() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none'); const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null); const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown(); const { data: currentRundown } = useRundown(null);
const { importRundown } = useSpreadsheetImport(); const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -74,6 +80,7 @@ export default function SourcesPanel() {
const worksheetOptions = await uploadExcel(fileToUpload); const worksheetOptions = await uploadExcel(fileToUpload);
setActiveSource({ setActiveSource({
kind: 'excel', kind: 'excel',
fileName: fileToUpload.name,
worksheetNames: worksheetOptions.worksheets, worksheetNames: worksheetOptions.worksheets,
initialWorksheetMetadata: worksheetOptions.metadata, initialWorksheetMetadata: worksheetOptions.metadata,
closedByUser: false, closedByUser: false,
@@ -97,6 +104,7 @@ export default function SourcesPanel() {
setHasFile('none'); setHasFile('none');
setActiveSource(null); setActiveSource(null);
setError(''); setError('');
setCompletedRundownTitle('');
}; };
const openGSheetFlow = () => { const openGSheetFlow = () => {
@@ -119,29 +127,41 @@ export default function SourcesPanel() {
} }
}; };
const handleFinished = () => { const handleFinished = (rundownTitle: string) => {
setImportFlow('finished'); setImportFlow('finished');
setHasFile('none'); setHasFile('none');
setActiveSource(null); setActiveSource(null);
setError(''); setError('');
setCompletedRundownTitle(rundownTitle);
}; };
const handleApplyImport = async (preview: SpreadsheetPreviewResponse) => { const handleApplyImport = async (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => {
if (mode === 'new') {
const title = newRundownTitle.trim() || preview.rundown.title;
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
handleFinished(title);
return;
}
if (!currentRundown) { if (!currentRundown) {
throw new Error('No current rundown loaded'); throw new Error('No current rundown loaded');
} }
await importRundown( // override or merge into the current rundown; merge uses providedFields to know which columns
{ // the sheet supplied so matched events keep the rest (e.g. automations)
[currentRundown.id]: { await applyImport({
...preview.rundown, mode,
id: currentRundown.id, targetRundownId: currentRundown.id,
title: currentRundown.title, rundown: preview.rundown,
}, customFields: preview.customFields,
}, providedFields,
preview.customFields, });
); handleFinished(currentRundown.title);
handleFinished();
}; };
const loadWorksheetMetadata = useCallback( const loadWorksheetMetadata = useCallback(
@@ -208,6 +228,13 @@ export default function SourcesPanel() {
if (activeSource.kind === 'excel') return 'excel'; if (activeSource.kind === 'excel') return 'excel';
return `gsheet:${activeSource.sheetId}`; return `gsheet:${activeSource.sheetId}`;
})(); })();
// suggested name when importing into a new rundown: the spreadsheet file name (without extension)
// for Excel, or the document title for Google Sheets
const spreadsheetName = (() => {
if (!activeSource) return '';
if (activeSource.kind === 'excel') return removeFileExtension(activeSource.fileName);
return activeSource.title;
})();
return ( return (
<Panel.Section> <Panel.Section>
@@ -267,11 +294,20 @@ export default function SourcesPanel() {
{showCompleted && ( {showCompleted && (
<div className={style.finishSection}> <div className={style.finishSection}>
<span className={style.finishBadge}>Import complete</span> <span className={style.finishBadge}>Import complete</span>
<div className={style.finishTitle}>Spreadsheet data applied.</div> <div className={style.finishTitle}>
<div className={style.finishDescription}>You can close this flow or start another import.</div> Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
<Button variant='subtle-white' onClick={resetFlow}> </div>
Reset flow <div className={style.finishDescription}>
</Button> Review the imported rundown in the editor or start another import.
</div>
<div className={style.finishActions}>
<Button variant='primary' onClick={() => navigate('/editor')}>
Open editor
</Button>
<Button variant='subtle-white' onClick={resetFlow}>
Import another
</Button>
</div>
</div> </div>
)} )}
{isGSheetFlow && ( {isGSheetFlow && (
@@ -287,6 +323,7 @@ export default function SourcesPanel() {
bodyElements={ bodyElements={
<SheetImportEditor <SheetImportEditor
sourceKey={sourceKey ?? 'spreadsheet'} sourceKey={sourceKey ?? 'spreadsheet'}
defaultRundownName={spreadsheetName}
worksheetNames={activeSource?.worksheetNames ?? []} worksheetNames={activeSource?.worksheetNames ?? []}
initialMetadata={activeSource?.initialWorksheetMetadata ?? null} initialMetadata={activeSource?.initialWorksheetMetadata ?? null}
loadMetadata={loadWorksheetMetadata} loadMetadata={loadWorksheetMetadata}
@@ -0,0 +1,55 @@
import type { RundownImportMode, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeEvent, isPlayableEvent, Playback } from 'ontime-types';
import { useEffect, useState } from 'react';
import Button from '../../../../../../common/components/buttons/Button';
import { usePlayback, useSelectedEventId } from '../../../../../../common/hooks/useSocket';
interface ApplyImportButtonProps {
preview: SpreadsheetPreviewResponse | null;
mode: RundownImportMode;
disabled: boolean;
loading: boolean;
onApply: () => void;
}
/**
* Apply action for the spreadsheet import.
* Subscribes to playback state on its own so playback updates do not re-render the whole editor.
* Requires a second click to confirm when applying would stop a running playback.
*/
export default function ApplyImportButton({ preview, mode, disabled, loading, onApply }: ApplyImportButtonProps) {
const playback = usePlayback();
const loadedEventId = useSelectedEventId();
// the loaded (playing) event loses its playback unless it still exists as a playable event after the import
const loadedEntry = loadedEventId ? preview?.rundown.entries[loadedEventId] : undefined;
const willLoadedEventBeOverriden = !(
loadedEntry !== undefined &&
isOntimeEvent(loadedEntry) &&
isPlayableEvent(loadedEntry)
);
// applying stops playback when creating a new rundown, or when the playing event does not survive
const willStopPlayback = playback !== Playback.Stop && (mode === 'new' || willLoadedEventBeOverriden);
// two-step confirmation before applying an import that stops playback
const [confirmStop, setConfirmStop] = useState(false);
useEffect(() => {
setConfirmStop(false);
}, [mode, preview]);
const handleClick = () => {
if (willStopPlayback && !confirmStop) {
setConfirmStop(true);
return;
}
onApply();
};
return (
<Button variant='primary' onClick={handleClick} disabled={disabled} loading={loading}>
{willStopPlayback && confirmStop ? 'Confirm — stop playback & apply import' : 'Apply import'}
</Button>
);
}
@@ -33,6 +33,11 @@
white-space: nowrap; white-space: nowrap;
} }
.importModeTrigger {
min-width: 12rem;
justify-content: space-between;
}
.addColumnTrigger { .addColumnTrigger {
justify-content: center; justify-content: center;
white-space: nowrap; white-space: nowrap;
@@ -87,6 +92,10 @@
font-weight: 600; font-weight: 600;
} }
.toolbarWarning {
color: $orange-400;
}
.mappingPaneTitle { .mappingPaneTitle {
align-self: center; align-self: center;
} }
@@ -1,10 +1,20 @@
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types'; import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import type { ImportMap } from 'ontime-utils'; import type { ImportMap } from 'ontime-utils';
import { IoArrowUpOutline, IoEye } from 'react-icons/io5'; import { useMemo } from 'react';
import { IoArrowUpOutline, IoCheckmark, IoChevronDown, IoEye, IoWarningOutline } from 'react-icons/io5';
import Button from '../../../../../../common/components/buttons/Button'; import Button from '../../../../../../common/components/buttons/Button';
import { DropdownMenu, DropdownMenuOption } from '../../../../../../common/components/dropdown-menu/DropdownMenu';
import Input from '../../../../../../common/components/input/input/Input';
import Select from '../../../../../../common/components/select/Select'; import Select from '../../../../../../common/components/select/Select';
import * as Panel from '../../../../panel-utils/PanelUtils'; import * as Panel from '../../../../panel-utils/PanelUtils';
import ApplyImportButton from './ApplyImportButton';
import { isIdColumnMapped } from './importMapUtils';
import PreviewTable from './preview/PreviewTable'; import PreviewTable from './preview/PreviewTable';
import SheetImportMappingPane from './SheetImportMappingPane'; import SheetImportMappingPane from './SheetImportMappingPane';
import { useSheetImportForm } from './useSheetImportForm'; import { useSheetImportForm } from './useSheetImportForm';
@@ -13,17 +23,46 @@ import style from './SheetImportEditor.module.scss';
interface SheetImportEditorProps { interface SheetImportEditorProps {
sourceKey: string; sourceKey: string;
defaultRundownName: string;
worksheetNames: string[]; worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null; initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>; loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>; previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>;
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>; onApply: (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onCancel: () => void; onCancel: () => void;
onExport?: (importMap: ImportMap) => Promise<void>; onExport?: (importMap: ImportMap) => Promise<void>;
} }
const importModeOptions: Array<{
value: RundownImportMode;
label: string;
description: string;
}> = [
{
value: 'override',
label: 'Replace current rundown',
description: 'Spreadsheet data completely replaces current rundown',
},
{
value: 'merge',
label: 'Merge with current rundown',
description: 'Merge entries referencing their IDs, entries not present in spreadsheet rundown are deleted',
},
{
value: 'new',
label: 'New rundown',
description: 'Create a new rundown to import the data into. Loads the new rundown',
},
];
export default function SheetImportEditor({ export default function SheetImportEditor({
sourceKey, sourceKey,
defaultRundownName,
worksheetNames, worksheetNames,
initialMetadata, initialMetadata,
loadMetadata, loadMetadata,
@@ -49,11 +88,16 @@ export default function SheetImportEditor({
isBusy, isBusy,
canPreview, canPreview,
displayError, displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit, handlePreviewSubmit,
handleExportSubmit, handleExportSubmit,
handleApply, handleApply,
} = useSheetImportForm({ } = useSheetImportForm({
sourceKey, sourceKey,
defaultRundownName,
worksheetNames, worksheetNames,
initialMetadata, initialMetadata,
loadMetadata, loadMetadata,
@@ -62,6 +106,19 @@ export default function SheetImportEditor({
onExport, onExport,
}); });
const selectedImportMode = importModeOptions.find((option) => option.value === importMode) ?? importModeOptions[0];
const importModeItems = useMemo<DropdownMenuOption[]>(
() =>
importModeOptions.map((option) => ({
type: 'item',
label: option.label,
description: option.description,
icon: importMode === option.value ? IoCheckmark : undefined,
onClick: () => setImportMode(option.value),
})),
[importMode, setImportMode],
);
return ( return (
<Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}> <Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}>
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}> <Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}>
@@ -75,7 +132,16 @@ export default function SheetImportEditor({
} }
/> />
</label> </label>
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>} {toolbarStatus && (
<Panel.Description>
{toolbarStatus.entries === ''
? 'No import preview yet'
: `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}${toolbarStatus.end} · ${toolbarStatus.duration}`}
{toolbarStatus.warnings > 0 && (
<span className={style.toolbarWarning}> · {toolbarStatus.warnings} warnings</span>
)}
</Panel.Description>
)}
</Panel.InlineElements> </Panel.InlineElements>
<div className={style.editorBody}> <div className={style.editorBody}>
@@ -93,13 +159,19 @@ export default function SheetImportEditor({
<section className={style.previewPane}> <section className={style.previewPane}>
<div className={style.previewPaneHeader}> <div className={style.previewPaneHeader}>
<span className={style.previewPaneTitle}>Import preview</span> <div className={style.previewPaneHeading}>
<span className={style.previewPaneTitle}>Import preview</span>
</div>
</div> </div>
<div className={style.tableShell}> <div className={style.tableShell}>
<PreviewTable <PreviewTable
preview={state.preview} preview={state.preview}
columnLabels={columnLabels} columnLabels={columnLabels}
canRefresh={canPreview}
isLoadingMetadata={isLoadingMetadata} isLoadingMetadata={isLoadingMetadata}
isRefreshing={state.loading === 'preview'}
needsPreviewRefresh={state.needsPreviewRefresh}
onRefresh={handlePreviewSubmit}
worksheetHeaders={worksheetHeaders} worksheetHeaders={worksheetHeaders}
/> />
</div> </div>
@@ -107,33 +179,63 @@ export default function SheetImportEditor({
</div> </div>
{displayError && <Panel.Error>{displayError}</Panel.Error>} {displayError && <Panel.Error>{displayError}</Panel.Error>}
<Panel.InlineElements align='end' wrap='wrap' className={style.editorFooter}> {importMode === 'merge' && !isIdColumnMapped(values) && (
<Button onClick={onCancel} disabled={isBusy}> <Panel.Description tone='warning'>
Cancel <IoWarningOutline /> No ID column mapped merge matches entries by ID, so it will behave like Replace. Export
</Button> your rundown to a spreadsheet first to keep its IDs.
{onExport && ( </Panel.Description>
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}> )}
<IoArrowUpOutline /> <Panel.InlineElements align='apart' wrap='wrap' className={style.editorFooter}>
Export <Panel.InlineElements wrap='wrap'>
<label className={style.worksheetControl}>
<span className={style.worksheetLabel}>Import mode</span>
<DropdownMenu
render={<Button className={style.importModeTrigger} variant='subtle-white' />}
items={importModeItems}
>
{selectedImportMode.label}
<IoChevronDown />
</DropdownMenu>
</label>
{importMode === 'new' && (
<label className={style.worksheetControl}>
<span className={style.worksheetLabel}>New rundown name</span>
<Input
value={newRundownTitle}
onChange={(event) => setNewRundownTitle(event.target.value)}
placeholder={state.preview?.rundown.title || 'Imported rundown'}
aria-label='New rundown name'
/>
</label>
)}
</Panel.InlineElements>
<Panel.InlineElements wrap='wrap'>
<Button onClick={onCancel} disabled={isBusy}>
Cancel
</Button> </Button>
)} {onExport && (
<Button <Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
variant={state.preview ? undefined : 'primary'} <IoArrowUpOutline />
onClick={handlePreviewSubmit} Export
disabled={!canPreview} </Button>
loading={state.loading === 'preview'} )}
> <Button
<IoEye /> variant={state.preview ? undefined : 'primary'}
Preview import onClick={handlePreviewSubmit}
</Button> disabled={!canPreview}
<Button loading={state.loading === 'preview'}
variant='primary' >
onClick={handleApply} <IoEye />
disabled={!state.preview || isBusy} Preview import
loading={state.loading === 'apply'} </Button>
> <ApplyImportButton
Apply import preview={state.preview}
</Button> mode={importMode}
disabled={!state.preview || isBusy}
loading={state.loading === 'apply'}
onApply={handleApply}
/>
</Panel.InlineElements>
</Panel.InlineElements> </Panel.InlineElements>
</Panel.Section> </Panel.Section>
); );
@@ -1,15 +1,21 @@
import { describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { import {
builtInFieldDefs, builtInFieldDefs,
convertToImportMap, convertToImportMap,
createDefaultFormValues, createDefaultFormValues,
defaultImportMode,
getImportWarnings, getImportWarnings,
getPersistedImportMode,
getProvidedImportFields,
getResolvedCustomFields, getResolvedCustomFields,
isIdColumnMapped,
persistImportMode,
} from '../importMapUtils'; } from '../importMapUtils';
const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue'); const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue');
const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title'); const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title');
const idIndex = builtInFieldDefs.findIndex((field) => field.importKey === 'id');
describe('getImportWarnings()', () => { describe('getImportWarnings()', () => {
it('warns when two mappings target the same spreadsheet column', () => { it('warns when two mappings target the same spreadsheet column', () => {
@@ -128,3 +134,64 @@ describe('convertToImportMap()', () => {
}); });
}); });
}); });
describe('getProvidedImportFields()', () => {
it('reports the mapped built-in and custom fields the sheet supplies', () => {
const values = createDefaultFormValues();
values.builtIn[titleIndex] = { header: 'title', enabled: true };
values.builtIn[cueIndex] = { header: '', enabled: false };
values.custom = [{ ontimeName: 'ignored', importName: 'FOH/Monitor' }];
const provided = getProvidedImportFields(convertToImportMap(values));
// enabled built-in mappings are reported as event fields, disabled ones are not
expect(provided.event).toContain('title');
expect(provided.event).not.toContain('cue');
// the id column is only used for matching, never overwritten
expect(provided.event).not.toContain('id');
// custom fields are reported symmetrically by their resolved Ontime name
expect(provided.custom).toStrictEqual(['FOH Monitor']);
});
});
describe('isIdColumnMapped()', () => {
it('is true when the ID field is enabled with a header', () => {
// the default form maps the ID column
expect(isIdColumnMapped(createDefaultFormValues())).toBe(true);
});
it('is false when the ID field is disabled', () => {
const values = createDefaultFormValues();
values.builtIn[idIndex] = { header: 'id', enabled: false };
expect(isIdColumnMapped(values)).toBe(false);
});
it('is false when the ID field header is blank', () => {
const values = createDefaultFormValues();
values.builtIn[idIndex] = { header: ' ', enabled: true };
expect(isIdColumnMapped(values)).toBe(false);
});
});
describe('import mode persistence', () => {
const sourceKey = 'excel-test';
beforeEach(() => {
localStorage.clear();
});
it('defaults to override when nothing is persisted', () => {
expect(getPersistedImportMode(sourceKey)).toBe('override');
expect(defaultImportMode).toBe('override');
});
it('round-trips a persisted value', () => {
persistImportMode(sourceKey, 'merge');
expect(getPersistedImportMode(sourceKey)).toBe('merge');
});
it('falls back to the default when the persisted value is invalid', () => {
persistImportMode(sourceKey, 'nonsense' as never);
expect(getPersistedImportMode(sourceKey)).toBe('override');
});
});
@@ -1,3 +1,4 @@
import type { ImportedFields, RundownImportMode } from 'ontime-types';
import type { ImportMap } from 'ontime-utils'; import type { ImportMap } from 'ontime-utils';
import { makeStageKey } from '../../../../../../common/utils/localStorage'; import { makeStageKey } from '../../../../../../common/utils/localStorage';
@@ -49,6 +50,16 @@ export function createDefaultFormValues(): ImportFormValues {
}; };
} }
/**
* Whether the mapping supplies an ID column. Merge matches entries by ID, so without one every
* imported entry gets a fresh id and nothing can reconcile with the current rundown.
*/
export function isIdColumnMapped(values: ImportFormValues): boolean {
const idIndex = builtInFieldDefs.findIndex((def) => def.importKey === 'id');
const field = values.builtIn[idIndex];
return Boolean(field?.enabled && field.header.trim());
}
function sanitiseOntimeCustomFieldLabel(importName: string): string { function sanitiseOntimeCustomFieldLabel(importName: string): string {
// Replace punctuation with spaces, then collapse repeated whitespace into single spaces. // Replace punctuation with spaces, then collapse repeated whitespace into single spaces.
const sanitised = importName const sanitised = importName
@@ -81,6 +92,24 @@ export function getResolvedCustomFields(customFields: ImportFormValues['custom']
}); });
} }
/**
* Returns the fields the import map supplies — the complete description of what the incoming data
* provides, for both built-in and custom fields. A merge uses this to patch exactly these fields
* onto a matched event and keep everything else (e.g. automations) untouched.
* Import-map keys are OntimeEvent field names; `worksheet`/`custom` are meta and `id` is only used
* for matching, not overwritten.
*/
export function getProvidedImportFields(importMap: ImportMap): ImportedFields {
const event: string[] = [];
for (const [key, value] of Object.entries(importMap)) {
if (key === 'worksheet' || key === 'custom' || key === 'id') continue;
if (typeof value === 'string' && value.trim() !== '') {
event.push(key);
}
}
return { event, custom: Object.keys(importMap.custom) };
}
export function convertToImportMap(values: ImportFormValues): ImportMap { export function convertToImportMap(values: ImportFormValues): ImportMap {
const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>( const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>(
(accumulator, { ontimeName, importName }) => { (accumulator, { ontimeName, importName }) => {
@@ -130,12 +159,12 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } { export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
const storageKey = getImportMapKey(sourceKey); const storageKey = getImportMapKey(sourceKey);
try { try {
const raw = localStorage.getItem(storageKey); const persistedData = localStorage.getItem(storageKey);
if (!raw) { if (!persistedData) {
return { values: createDefaultFormValues(), isPersisted: false }; return { values: createDefaultFormValues(), isPersisted: false };
} }
const parsed: unknown = JSON.parse(raw); const parsed: unknown = JSON.parse(persistedData);
if (isPersistedFormValues(parsed)) { if (isPersistedFormValues(parsed)) {
return { values: parsed, isPersisted: true }; return { values: parsed, isPersisted: true };
} }
@@ -150,6 +179,32 @@ export function getPersistedImportState(sourceKey: string): { values: ImportForm
} }
} }
/**
* The import mode (new / merge / override) is persisted separately from the field mapping
* so the mapping schema guard stays untouched.
*/
/** Default import mode: replace matched elements in the current rundown */
export const defaultImportMode: RundownImportMode = 'override';
function getImportModeKey(sourceKey: string) {
return makeStageKey(`import-mode:${sourceKey}`);
}
/** Persists the import mode for a given source */
export function persistImportMode(sourceKey: string, mode: RundownImportMode) {
localStorage.setItem(getImportModeKey(sourceKey), mode);
}
/** Reads the persisted import mode for a source, falling back to the default when absent or invalid */
export function getPersistedImportMode(sourceKey: string): RundownImportMode {
const persisted = localStorage.getItem(getImportModeKey(sourceKey));
if (persisted === 'new' || persisted === 'merge' || persisted === 'override') {
return persisted;
}
return defaultImportMode;
}
/** /**
* Validates import mappings and generates warnings for duplicate or missing spreadsheet columns. * Validates import mappings and generates warnings for duplicate or missing spreadsheet columns.
*/ */
@@ -1,27 +1,35 @@
.emptyState { .emptyState {
height: 100%; padding: 3rem 1.5rem;
min-height: 16rem;
display: grid;
place-content: center;
gap: 0.35rem;
padding: 1.5rem;
text-align: center; text-align: center;
} }
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
.emptyTitle { .emptyTitle {
color: $ui-white; margin-bottom: 0.25rem;
font-size: 1rem; color: rgba($gray-200, 0.72);
font-weight: 600; font-size: calc(1rem + 2px);
font-weight: 400;
} }
.emptyBody { .emptyBody {
color: $gray-400; color: rgba($gray-200, 0.55);
font-size: 0.95rem; font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
margin: 1rem auto 0;
} }
.table { .table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: separate;
border-spacing: 0;
color: $ui-white;
font-size: calc(1rem - 2px); font-size: calc(1rem - 2px);
text-align: left; text-align: left;
table-layout: auto; table-layout: auto;
@@ -34,31 +42,101 @@
} }
th { th {
font-weight: 400; color: $gray-300;
color: $gray-400; font-size: 0.8rem;
text-transform: capitalize; font-weight: 600;
vertical-align: top; letter-spacing: 0.02em;
text-transform: uppercase;
vertical-align: bottom;
white-space: normal; white-space: normal;
} }
th, th,
td { td {
padding: 0.5rem; box-sizing: border-box;
min-width: 8rem; min-width: 8rem;
vertical-align: top; max-width: 20rem;
padding: 0.55rem 0.65rem;
border-bottom: 1px solid $white-10;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
} }
tr:nth-child(even) { tbody tr {
background-color: $white-1; --entry-colour: transparent;
background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%);
box-shadow: inset 3px 0 var(--entry-colour);
} }
td[data-empty='true'] {
color: $gray-600;
}
}
.rowNumber,
.rowType {
position: sticky;
z-index: 1;
background-color: inherit;
} }
.rowNumber { .rowNumber {
width: 4.5rem; left: 0;
min-width: 4.5rem; width: 3.25rem;
min-width: 3.25rem !important;
color: $gray-400;
text-align: right;
} }
.rowType { .rowType {
width: 7rem; left: 3.25rem;
min-width: 7rem; width: 6.25rem;
min-width: 6.25rem !important;
color: $gray-400;
font-size: 0.8rem;
box-shadow: 1px 0 $white-10;
}
thead .rowNumber,
thead .rowType {
z-index: 2;
background-color: $gray-1350;
}
.numericCell {
font-variant-numeric: tabular-nums;
text-align: right;
}
.multilineCell {
max-width: 30rem !important;
overflow-wrap: anywhere;
text-overflow: clip !important;
white-space: pre-wrap !important;
}
.eventRow {
.rowNumber {
font-variant-numeric: tabular-nums;
}
}
.groupRow {
background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important;
box-shadow: inset 4px 0 var(--entry-colour) !important;
font-weight: 600;
td {
min-height: 3.25rem;
border-top: 0.75rem solid $gray-1350;
}
}
.milestoneRow {
background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important;
box-shadow: inset 3px 0 var(--entry-colour) !important;
color: $gray-300;
font-style: italic;
} }
@@ -1,7 +1,11 @@
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types'; import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import type { CSSProperties } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import Button from '../../../../../../../common/components/buttons/Button';
import Tag from '../../../../../../../common/components/tag/Tag';
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
import { getCellValue } from './previewTableUtils'; import { getCellValue } from './previewTableUtils';
import style from './PreviewTable.module.scss'; import style from './PreviewTable.module.scss';
@@ -9,14 +13,85 @@ import style from './PreviewTable.module.scss';
interface PreviewTableProps { interface PreviewTableProps {
preview: SpreadsheetPreviewResponse | null; preview: SpreadsheetPreviewResponse | null;
columnLabels: string[]; columnLabels: string[];
canRefresh: boolean;
isLoadingMetadata: boolean; isLoadingMetadata: boolean;
isRefreshing: boolean;
needsPreviewRefresh: boolean;
onRefresh: () => void;
worksheetHeaders: string[]; worksheetHeaders: string[];
} }
const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration'];
const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']);
const transparentColour = 'transparent';
type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string];
function getEntryDisplay(entry: PreviewEntry, groupColour?: string) {
if (isOntimeGroup(entry)) {
return {
rowClassName: style.groupRow,
entryColour: entry.colour,
entryType: 'Group',
};
}
const entryColour = groupColour ?? transparentColour;
if (isOntimeMilestone(entry)) {
return {
rowClassName: style.milestoneRow,
entryColour,
entryType: 'Milestone',
};
}
if (isOntimeDelay(entry)) {
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Delay',
};
}
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Event',
};
}
function getCellClassName(label: string, value: string) {
if (value.includes('\n')) {
return style.multilineCell;
}
if (numericColumns.has(label)) {
return style.numericCell;
}
return undefined;
}
function getDisplayColumns(columnLabels: string[]) {
return [...columnLabels].sort((left, right) => {
const leftPriority = priorityColumns.indexOf(left);
const rightPriority = priorityColumns.indexOf(right);
return (
(leftPriority === -1 ? priorityColumns.length : leftPriority) -
(rightPriority === -1 ? priorityColumns.length : rightPriority)
);
});
}
export default function PreviewTable({ export default function PreviewTable({
preview, preview,
columnLabels, columnLabels,
canRefresh,
isLoadingMetadata, isLoadingMetadata,
isRefreshing,
needsPreviewRefresh,
onRefresh,
worksheetHeaders, worksheetHeaders,
}: PreviewTableProps) { }: PreviewTableProps) {
const customFieldKeyByLabel = useMemo(() => { const customFieldKeyByLabel = useMemo(() => {
@@ -24,33 +99,57 @@ export default function PreviewTable({
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId])); return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
}, [preview]); }, [preview]);
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
const previewMetadata = useMemo(() => {
if (!preview) return null;
return getRundownMetadata(preview.rundown, null);
}, [preview]);
if (!preview) { if (!preview) {
let emptyTitle = 'Preview not generated';
let emptyContent = 'Select the fields you want to import, then click Preview import.'; let emptyContent = 'Select the fields you want to import, then click Preview import.';
if (isLoadingMetadata) { if (isLoadingMetadata) {
emptyTitle = 'Loading worksheet';
emptyContent = 'Loading worksheet metadata...'; emptyContent = 'Loading worksheet metadata...';
} else if (worksheetHeaders.length === 0) { } else if (worksheetHeaders.length === 0) {
emptyTitle = 'No headers found';
emptyContent = emptyContent =
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.'; 'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
} else if (needsPreviewRefresh) {
emptyTitle = 'Preview needs updating';
emptyContent = 'Your column mapping changed. Preview the import again to update this table.';
} }
return ( return (
<div className={style.emptyState}> <div className={style.emptyState}>
<div className={style.emptyTitle}>Preview not generated</div> <div className={style.emptyMessage}>
<div className={style.emptyBody}>{emptyContent}</div> <div className={style.emptyTitle}>{emptyTitle}</div>
<div className={style.emptyBody}>{emptyContent}</div>
{needsPreviewRefresh && (
<Button
className={style.emptyAction}
variant='primary'
onClick={onRefresh}
disabled={!canRefresh}
loading={isRefreshing}
>
Refresh preview
</Button>
)}
</div>
</div> </div>
); );
} }
let eventIndex = 0;
return ( return (
<table className={style.table}> <table className={style.table}>
<thead> <thead>
<tr> <tr>
<th className={style.rowNumber}>#</th> <th className={style.rowNumber}>#</th>
<th className={style.rowType}>Type</th> <th className={style.rowType}>Type</th>
{columnLabels.map((label, index) => ( {displayColumns.map((label, index) => (
<th key={`${label}-${index}`}>{label}</th> <th key={`${label}-${index}`}>{label}</th>
))} ))}
</tr> </tr>
@@ -59,16 +158,29 @@ export default function PreviewTable({
{preview.rundown.flatOrder.map((entryId) => { {preview.rundown.flatOrder.map((entryId) => {
const entry = preview.rundown.entries[entryId]; const entry = preview.rundown.entries[entryId];
const isEvent = isOntimeEvent(entry); const isEvent = isOntimeEvent(entry);
if (isEvent) eventIndex++; const entryMetadata = previewMetadata?.[entryId];
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry); const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
return ( return (
<tr key={entryId}> <tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td> <td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
<td className={style.rowType}>{hasType ? entry.type : ''}</td> <td className={style.rowType}>
{columnLabels.map((label, colIndex) => ( <Tag>{entryType}</Tag>
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td> </td>
))} {displayColumns.map((label, colIndex) => {
const value = getCellValue(label, entry, customFieldKeyByLabel);
const cellClassName = getCellClassName(label, value);
return (
<td
key={`${entryId}-${colIndex}`}
className={cellClassName}
data-empty={value === ''}
title={value || undefined}
>
{value}
</td>
);
})}
</tr> </tr>
); );
})} })}
@@ -1,7 +1,13 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types'; import type {
import { millisToString } from 'ontime-utils'; ImportedFields,
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { maybeAxiosError } from '../../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../../common/api/utils';
@@ -11,8 +17,11 @@ import {
builtInFieldDefs, builtInFieldDefs,
convertToImportMap, convertToImportMap,
getImportWarnings, getImportWarnings,
getPersistedImportMode,
getPersistedImportState, getPersistedImportState,
getProvidedImportFields,
getResolvedCustomFields, getResolvedCustomFields,
persistImportMode,
persistImportState, persistImportState,
} from './importMapUtils'; } from './importMapUtils';
import { deriveHeaderOptionsState } from './spreadsheetImportUtils'; import { deriveHeaderOptionsState } from './spreadsheetImportUtils';
@@ -24,7 +33,7 @@ type ImportAction =
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse } | { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
| { type: 'applySuccess' } | { type: 'applySuccess' }
| { type: 'exportSuccess' } | { type: 'exportSuccess' }
| { type: 'clearPreview'; error?: string } | { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
| { type: 'failure'; error: string } | { type: 'failure'; error: string }
| { type: 'reset' }; | { type: 'reset' };
@@ -32,12 +41,14 @@ type ImportState = {
loading: '' | 'preview' | 'apply' | 'export'; loading: '' | 'preview' | 'apply' | 'export';
error: string; error: string;
preview: SpreadsheetPreviewResponse | null; preview: SpreadsheetPreviewResponse | null;
needsPreviewRefresh: boolean;
}; };
const initialImportState: ImportState = { const initialImportState: ImportState = {
loading: '', loading: '',
error: '', error: '',
preview: null, preview: null,
needsPreviewRefresh: false,
}; };
function importReducer(state: ImportState, action: ImportAction): ImportState { function importReducer(state: ImportState, action: ImportAction): ImportState {
@@ -49,15 +60,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
case 'startExport': case 'startExport':
return { ...state, loading: 'export', error: '' }; return { ...state, loading: 'export', error: '' };
case 'previewSuccess': case 'previewSuccess':
return { loading: '', error: '', preview: action.preview }; return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
case 'applySuccess': case 'applySuccess':
case 'exportSuccess': case 'exportSuccess':
return { ...state, loading: '' }; return { ...state, loading: '' };
case 'clearPreview': case 'clearPreview':
return { ...state, error: action.error ?? '', preview: null }; return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
case 'failure': { case 'failure': {
if (state.loading === 'preview') { if (state.loading === 'preview') {
return { loading: '', error: action.error, preview: null }; return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
} }
return { ...state, loading: '', error: action.error }; return { ...state, loading: '', error: action.error };
} }
@@ -100,16 +111,23 @@ function buildColumnLabels(values: ImportFormValues): string[] {
interface UseSheetImportFormProps { interface UseSheetImportFormProps {
sourceKey: string; sourceKey: string;
defaultRundownName: string;
worksheetNames: string[]; worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null; initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>; loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>; previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>;
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>; onApply: (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>; onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>;
} }
export function useSheetImportForm({ export function useSheetImportForm({
sourceKey, sourceKey,
defaultRundownName,
worksheetNames, worksheetNames,
initialMetadata, initialMetadata,
loadMetadata, loadMetadata,
@@ -170,6 +188,8 @@ export function useSheetImportForm({
const columnLabels = buildColumnLabels(values); const columnLabels = buildColumnLabels(values);
const [state, dispatch] = useReducer(importReducer, initialImportState); const [state, dispatch] = useReducer(importReducer, initialImportState);
const [importMode, setImportMode] = useState<RundownImportMode>(() => getPersistedImportMode(sourceKey));
const [newRundownTitle, setNewRundownTitle] = useState(defaultRundownName);
const warnings = getImportWarnings(values, headers); const warnings = getImportWarnings(values, headers);
const warningCount = Object.values(warnings).filter(Boolean).length; const warningCount = Object.values(warnings).filter(Boolean).length;
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null); const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
@@ -182,6 +202,12 @@ export function useSheetImportForm({
dispatch({ type: 'reset' }); dispatch({ type: 'reset' });
}, [initialFormValues, reset]); }, [initialFormValues, reset]);
// Update import mode and rundown name if the source changes
useEffect(() => {
setImportMode(getPersistedImportMode(sourceKey));
setNewRundownTitle(defaultRundownName);
}, [sourceKey, defaultRundownName]);
// Keep the worksheet selection valid if the available worksheets change underneath the form. // Keep the worksheet selection valid if the available worksheets change underneath the form.
useEffect(() => { useEffect(() => {
if (worksheetNames.length === 0) return; if (worksheetNames.length === 0) return;
@@ -198,7 +224,7 @@ export function useSheetImportForm({
const sub = watch(() => { const sub = watch(() => {
if (!previewRef.current) return; if (!previewRef.current) return;
previewRef.current = null; previewRef.current = null;
dispatch({ type: 'clearPreview' }); dispatch({ type: 'clearPreview', needsRefresh: true });
}); });
return () => sub.unsubscribe(); return () => sub.unsubscribe();
}, [watch]); }, [watch]);
@@ -241,13 +267,15 @@ export function useSheetImportForm({
try { try {
dispatch({ type: 'startApply' }); dispatch({ type: 'startApply' });
await onApply(state.preview); const providedFields = getProvidedImportFields(convertToImportMap(getValues()));
await onApply(state.preview, importMode, newRundownTitle, providedFields);
persistImportState(sourceKey, getValues()); persistImportState(sourceKey, getValues());
persistImportMode(sourceKey, importMode);
dispatch({ type: 'applySuccess' }); dispatch({ type: 'applySuccess' });
} catch (error) { } catch (error) {
dispatch({ type: 'failure', error: maybeAxiosError(error) }); dispatch({ type: 'failure', error: maybeAxiosError(error) });
} }
}, [getValues, onApply, sourceKey, state.preview]); }, [getValues, importMode, newRundownTitle, onApply, sourceKey, state.preview]);
const handleExport = useCallback( const handleExport = useCallback(
async (formValues: ImportFormValues) => { async (formValues: ImportFormValues) => {
@@ -272,15 +300,37 @@ export function useSheetImportForm({
}, [append]); }, [append]);
const toolbarStatus = (() => { const toolbarStatus = (() => {
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
if (!state.preview) { if (!state.preview) {
return `entries: | start: | end: | duration: ${warningText}`; return {
entries: '',
groups: '',
milestones: '',
start: '',
end: '',
duration: '',
warnings: warningCount,
};
} }
const { flatOrder } = state.preview.rundown; const { entries, flatOrder } = state.preview.rundown;
const { start, end, duration } = state.preview.summary; const { start, end, duration } = state.preview.summary;
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`; let groups = 0;
let milestones = 0;
for (const entryId of flatOrder) {
const entry = entries[entryId];
if (isOntimeGroup(entry)) groups++;
else if (isOntimeMilestone(entry)) milestones++;
}
return {
entries: flatOrder.length,
groups,
milestones,
start: removeTrailingZero(millisToString(start)),
end: removeTrailingZero(millisToString(end)),
duration: formatDuration(duration),
warnings: warningCount,
};
})(); })();
return { return {
@@ -300,6 +350,10 @@ export function useSheetImportForm({
isBusy, isBusy,
canPreview, canPreview,
displayError, displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit: handleSubmit(handlePreview), handlePreviewSubmit: handleSubmit(handlePreview),
handleExportSubmit: handleSubmit(handleExport), handleExportSubmit: handleSubmit(handleExport),
handleApply, handleApply,
@@ -1,30 +1,17 @@
import { useQueryClient } from '@tanstack/react-query'; import { RundownImportPayload } from 'ontime-types';
import { CustomFields, ProjectRundowns } from 'ontime-types';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants'; import { importRundownWithOptions } from '../../../../../common/api/rundown';
import { patchData } from '../../../../../common/api/db';
export default function useSpreadsheetImport() { export default function useSpreadsheetImport() {
const queryClient = useQueryClient(); /** applies a spreadsheet import: override or merge into the current rundown, or create a new one */
const applyImport = useCallback(async (payload: RundownImportPayload) => {
/** applies rundown and customFields to current project */ // the backend broadcasts a refetch once the rundown is parsed and applied, so the caches update
const importRundown = useCallback( // through that single path rather than racing it with an optimistic write from here
async (rundowns: ProjectRundowns, customFields: CustomFields) => { await importRundownWithOptions(payload);
await patchData({ rundowns, customFields }); }, []);
// we are unable to optimistically set the rundown since we need
// it to be normalised
await queryClient.invalidateQueries({
queryKey: RUNDOWN,
});
await queryClient.invalidateQueries({
queryKey: CUSTOM_FIELDS,
});
},
[queryClient],
);
return { return {
importRundown, applyImport,
}; };
} }
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { Suspense, useState } from 'react';
import { IoArrowDown, IoArrowUp } from 'react-icons/io5'; import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
@@ -11,11 +11,25 @@ import style from './ProjectPanel.module.scss';
type SortParameter = 'alphabetical' | 'modified'; type SortParameter = 'alphabetical' | 'modified';
export default function ProjectList() { export default function ProjectList() {
return (
<Suspense
fallback={
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
}
>
<ProjectListSuspend />
</Suspense>
);
}
function ProjectListSuspend() {
const [editingMode, setEditingMode] = useState<EditMode | null>(null); const [editingMode, setEditingMode] = useState<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null); const [editingFilename, setEditingFilename] = useState<string | null>(null);
const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc'); const [sortMode, setSortMode] = useState<ProjectSortMode>('modified-desc');
const { data, refetch, status } = useOrderedProjectList(sortMode); const { data, refetch } = useOrderedProjectList(sortMode);
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => { const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode)); setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
@@ -38,14 +52,6 @@ export default function ProjectList() {
}); });
}; };
if (status === 'pending') {
return (
<div className={style.empty}>
<Panel.Loader isLoading />
</div>
);
}
const numProjects = data.reorderedProjectFiles.length; const numProjects = data.reorderedProjectFiles.length;
return ( return (
@@ -2,33 +2,23 @@ import { useEffect, useState } from 'react';
import { generateUrl } from '../../../../common/api/session'; import { generateUrl } from '../../../../common/api/session';
import CopyTag from '../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo'; import { serverURL } from '../../../../externals';
import { isOntimeCloud, serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
export default function McpSection() { export default function McpSection() {
const { data: infoData } = useInfo();
const [mcpEndpointUrl, setMcpEndpointUrl] = useState(''); const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
// generate url // generate url
useEffect(() => { useEffect(() => {
const baseUrl = (() => { // the page origin is reachable wherever the user is browsing from,
if (isOntimeCloud) return serverURL; // and follows the deployment (cloud, reverse proxy, port mappings)
// for local setups we prefer the localhost IP to avoid remote access
if (infoData.networkInterfaces.length > 0) {
return `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`;
}
return serverURL;
})();
// we are reusing the endpoint, so locking config and nav have no effect // we are reusing the endpoint, so locking config and nav have no effect
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false }) generateUrl({ baseUrl: serverURL, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
.then(setMcpEndpointUrl) .then(setMcpEndpointUrl)
.catch(() => { .catch(() => {
setMcpEndpointUrl(''); setMcpEndpointUrl('');
}); });
}, [infoData]); }, []);
const mcpClientConfig = mcpEndpointUrl const mcpClientConfig = mcpEndpointUrl
? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2) ? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2)
@@ -40,14 +30,19 @@ export default function McpSection() {
<Panel.SubHeader>MCP Server</Panel.SubHeader> <Panel.SubHeader>MCP Server</Panel.SubHeader>
<Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph> <Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph>
<Panel.Divider /> <Panel.Divider />
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' /> <Panel.ListGroup>
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>} <Panel.ListItem>
<Panel.Divider /> <Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
<Panel.Field {mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
title='Client configuration snippet' </Panel.ListItem>
description='Paste this into your AI agent settings under "mcpServers"' <Panel.ListItem>
/> <Panel.Field
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>} title='Client configuration snippet'
description='Paste this into your AI agent settings under "mcpServers"'
/>
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
); );
@@ -73,6 +73,10 @@
margin-right: 0.25rem; margin-right: 0.25rem;
} }
.tagOvertime {
color: $playback-over;
}
.time { .time {
color: $section-white; color: $section-white;
font-size: $text-body-size; font-size: $text-body-size;
@@ -1,12 +1,13 @@
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types'; import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
import { dayInMs, millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import AppLink from '../../../../common/components/link/app-link/AppLink'; import AppLink from '../../../../common/components/link/app-link/AppLink';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import useReport from '../../../../common/hooks-query/useReport'; import useReport from '../../../../common/hooks-query/useReport';
import { useTimer } from '../../../../common/hooks/useSocket'; import { useTimerProgress } from '../../../../common/hooks/useSocket';
import { formatDuration } from '../../../../common/utils/time'; import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, normaliseWallClock } from '../../../../common/utils/time';
import TimerDisplay from '../timer-display/TimerDisplay'; import TimerDisplay from '../timer-display/TimerDisplay';
import style from './PlaybackTimer.module.scss'; import style from './PlaybackTimer.module.scss';
@@ -24,15 +25,14 @@ function resolveAddedTimeLabel(addedTime: number) {
} }
export default function PlaybackTimer({ children }: PropsWithChildren) { export default function PlaybackTimer({ children }: PropsWithChildren) {
const timer = useTimer(); 'use memo';
const timer = useTimerProgress();
const isRolling = timer.playback === Playback.Roll; const isRolling = timer.playback === Playback.Roll;
const isWaiting = timer.phase === TimerPhase.Pending; const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime; const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime); const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : ''; const rollLabel = isRolling ? 'Roll mode active' : '';
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime); const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
return ( return (
@@ -51,7 +51,13 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
{isWaiting ? ( {isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span> <span className={style.rolltag}>Roll: Countdown to start</span>
) : ( ) : (
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} /> <RunningStatus
startedAt={timer.startedAt}
expectedFinish={timer.expectedFinish}
isStopped={timer.playback === Playback.Stop}
isCountToEnd={timer.isCountToEnd}
isOvertime={isOvertime}
/>
)} )}
</div> </div>
{children} {children}
@@ -62,16 +68,18 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
interface RunningStatusProps { interface RunningStatusProps {
startedAt: MaybeNumber; startedAt: MaybeNumber;
expectedFinish: MaybeNumber; expectedFinish: MaybeNumber;
playback: Playback; isStopped: boolean;
isCountToEnd: boolean;
isOvertime: boolean;
} }
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
if (playback === Playback.Stop) { function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isOvertime }: RunningStatusProps) {
if (isStopped) {
return <StoppedStatus />; return <StoppedStatus />;
} }
const started = millisToString(startedAt); const started = millisToString(startedAt);
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null; const finish = millisToString(expectedFinish === null ? null : normaliseWallClock(expectedFinish));
const finish = millisToString(finishedMs);
return ( return (
<> <>
@@ -80,7 +88,9 @@ function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusPro
<span className={style.time}>{started}</span> <span className={style.time}>{started}</span>
</span> </span>
<span className={style.finish}> <span className={style.finish}>
<span className={style.tag}>Expect end</span> <span className={cx([style.tag, isOvertime && style.tagOvertime])}>
{isCountToEnd ? 'Scheduled end' : 'Expected end'}
</span>
<span className={style.time}>{finish}</span> <span className={style.time}>{finish}</span>
</span> </span>
</> </>
@@ -180,7 +180,13 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
return ( return (
<Fragment key={entry.id}> <Fragment key={entry.id}>
<OperatorGroup key={entry.id} title={entry.title} /> <OperatorGroup
key={entry.id}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
duration={entry.duration}
/>
{entry.entries.map((nestedEntryId) => { {entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId]; const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) { if (!isOntimeEvent(nestedEntry)) {
@@ -217,6 +223,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
isLinkedToLoaded={isLinkedToLoaded} isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded} isSelected={isLoaded}
isPast={isPast} isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined} selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart} showStart={showStart}
subscribed={subscribedData} subscribed={subscribedData}
@@ -22,17 +22,39 @@
background-color: $gray-1250; background-color: $gray-1250;
} }
&.grouped {
position: relative;
padding-left: 0.35rem;
background:
linear-gradient(90deg, color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%), transparent 8rem),
$viewer-card-bg-color;
}
&.running { &.running {
border-top: 1px solid $gray-1300; border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $active-green); background-color: var(--operator-running-bg-override, $active-green);
} }
&.grouped.running {
background:
linear-gradient(90deg, color-mix(in srgb, transparent 82%, var(--group-colour, $gray-500) 18%), transparent 8rem),
var(--operator-running-bg-override, $active-green);
}
&.past { &.past {
border-top: 1px solid transparent; border-top: 1px solid transparent;
opacity: 0.2; opacity: 0.2;
} }
} }
.groupRail {
position: absolute;
inset-block: 0;
left: 0;
width: 0.35rem;
background-color: var(--group-colour, $gray-500);
}
.binder { .binder {
grid-area: binder; grid-area: binder;
color: $section-white; color: $section-white;
@@ -74,6 +96,9 @@
.plannedStart, .plannedStart,
.timeUntil, .timeUntil,
.runningTime { .runningTime {
display: flex;
align-items: center;
gap: 0.25rem;
border-radius: $component-border-radius-md; border-radius: $component-border-radius-md;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
line-height: 1; line-height: 1;
@@ -95,6 +120,21 @@
letter-spacing: 1px; letter-spacing: 1px;
} }
.live {
color: $ui-black;
background-color: $ui-white;
}
.due {
color: $ui-black;
background-color: $orange-500;
}
.done {
color: $white-60;
background-color: $white-7;
}
.runningTime { .runningTime {
grid-area: running; grid-area: running;
font-size: 1.25rem; font-size: 1.25rem;
@@ -25,6 +25,7 @@ interface OperatorEventProps {
isLinkedToLoaded: boolean; isLinkedToLoaded: boolean;
isSelected: boolean; isSelected: boolean;
isPast: boolean; isPast: boolean;
groupColour?: string;
selectedRef?: RefObject<HTMLDivElement | null>; selectedRef?: RefObject<HTMLDivElement | null>;
showStart: boolean; showStart: boolean;
subscribed: Subscribed; subscribed: Subscribed;
@@ -46,6 +47,7 @@ function OperatorEvent({
isLinkedToLoaded, isLinkedToLoaded,
isSelected, isSelected,
isPast, isPast,
groupColour,
selectedRef, selectedRef,
showStart, showStart,
subscribed, subscribed,
@@ -68,7 +70,12 @@ function OperatorEvent({
const mouseHandlers = useLongPress(handleLongPress); const mouseHandlers = useLongPress(handleLongPress);
const cueColours = colour && getAccessibleColour(colour); const cueColours = colour && getAccessibleColour(colour);
const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]); const operatorClasses = cx([
style.event,
groupColour && style.grouped,
isSelected && style.running,
isPast && style.past,
]);
const hasFields = subscribed.some((field) => field.value); const hasFields = subscribed.some((field) => field.value);
const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0; const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
@@ -85,8 +92,10 @@ function OperatorEvent({
data-testid={cue} data-testid={cue}
ref={selectedRef} ref={selectedRef}
onContextMenu={handleLongPress} onContextMenu={handleLongPress}
style={groupColour ? ({ '--group-colour': groupColour } as CSSProperties) : undefined}
{...mouseHandlers} {...mouseHandlers}
> >
{groupColour && <div className={style.groupRail} />}
<div className={style.binder} style={{ ...cueColours }}> <div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span> <span className={style.cue}>{cue}</span>
</div> </div>
@@ -153,11 +162,11 @@ function OperatorEventSchedule({
isLinkedToLoaded, isLinkedToLoaded,
}: OperatorEventScheduleProps) { }: OperatorEventScheduleProps) {
if (isPast) { if (isPast) {
return <span className={style.timeUntil}>DONE</span>; return <span className={cx([style.timeUntil, style.done])}>DONE</span>;
} }
if (isSelected) { if (isSelected) {
return <span className={style.timeUntil}>LIVE</span>; return <span className={cx([style.timeUntil, style.live])}>LIVE</span>;
} }
return ( return (
@@ -186,7 +195,7 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`; const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
return ( return (
<span className={style.timeUntil} data-testid='time-until'> <span className={cx([style.timeUntil, isDue && style.due])} data-testid='time-until'>
{timeUntilString} {timeUntilString}
</span> </span>
); );
@@ -1,13 +1,33 @@
.group { .group {
width: 100%; width: 100%;
padding: 0.25rem 0.5rem; 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: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 1rem;
letter-spacing: 0;
} }
// tablet .title {
@media (min-width: $min-tablet) { flex: 1 1 auto;
.group { min-width: 0;
padding: 0.25rem 1rem; overflow: hidden;
} text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.5rem;
color: color-mix(in srgb, currentColor 60%, transparent);
font-size: 0.875rem;
font-weight: 500;
} }
@@ -1,12 +1,29 @@
import { memo } from 'react'; import { CSSProperties, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time';
import style from './OperatorGroup.module.scss'; import style from './OperatorGroup.module.scss';
interface OperatorGroup { interface OperatorGroup {
title: string; title: string;
colour: string;
count: number;
duration: number;
} }
export default memo(OperatorGroup); export default memo(OperatorGroup);
function OperatorGroup({ title }: OperatorGroup) { function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
return <div className={style.group}>{title}</div>; const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour);
return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
<span className={style.title}>{title}</span>
<span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
<span>{formatDuration(duration)}</span>
</span>
</div>
);
} }
@@ -14,7 +14,7 @@ export interface OperatorData {
} }
export function useOperatorData(): ViewData<OperatorData> { export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(); const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata(null);
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings(); const { data: settings, status: settingsStatus } = useSettings();
@@ -1,9 +1,10 @@
import { ErrorBoundary } from '@sentry/react'; import { ErrorBoundary } from '@sentry/react';
import { PropsWithChildren, ReactNode } from 'react'; import { PropsWithChildren, ReactNode, Suspense } from 'react';
import ScrollArea from '../../common/components/scroll-area/ScrollArea'; import ScrollArea from '../../common/components/scroll-area/ScrollArea';
import { useIsOnline } from '../../common/hooks/useSocket'; import { useIsOnline } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import Loader from '../../views/common/loader/Loader';
import style from './Overview.module.scss'; import style from './Overview.module.scss';
@@ -15,18 +16,37 @@ export function OverviewWrapper({ navElements, children }: PropsWithChildren<Ove
const isOnline = useIsOnline(); const isOnline = useIsOnline();
return ( return (
<div className={cx([style.overview, !isOnline && style.isOffline])}> <Suspense fallback={<OverviewFallback navElements={navElements} />}>
<ErrorBoundary> <div className={cx([style.overview, !isOnline && style.isOffline])}>
<div className={style.nav}>{navElements}</div> <ErrorBoundary>
<ScrollArea <div className={style.nav}>{navElements}</div>
className={style.infoScroll} <ScrollArea
contentClassName={style.info} className={style.infoScroll}
contentStyle={{ minWidth: '100%' }} contentClassName={style.info}
orientation='horizontal' contentStyle={{ minWidth: '100%' }}
> orientation='horizontal'
{children} >
</ScrollArea> {children}
</ErrorBoundary> </ScrollArea>
</ErrorBoundary>
</div>
</Suspense>
);
}
function OverviewFallback({ navElements }: OverviewWrapperProps) {
return (
<div className={style.overview}>
<div className={style.nav}>{navElements}</div>
<ScrollArea
className={style.infoScroll}
contentClassName={style.info}
contentStyle={{ minWidth: '100%' }}
orientation='horizontal'
>
{/* TODO: this could be alined in a nicer way */}
<Loader />
</ScrollArea>
</div> </div>
); );
} }
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() { function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView(); const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId(); const currentGroupId = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null; const group = useEntry(null, currentGroupId) as OntimeGroup | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -266,7 +266,7 @@ function GroupTimes() {
function FlagTimes() { function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView(); const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag(); const { id, expectedStart } = useNextFlag();
const entry = useEntry(id) as OntimeEvent | null; const entry = useEntry(null, id) as OntimeEvent | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback); const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -6,7 +6,7 @@ import style from './TitleOverview.module.scss';
export default function TitleOverview() { export default function TitleOverview() {
'use memo'; 'use memo';
const { data: projectData } = useProjectData(); const { data: projectData } = useProjectData();
const { data: rundownData } = useRundownAuxData(); const { data: rundownData } = useRundownAuxData(null);
if (!projectData.title && !rundownData.title) { if (!projectData.title && !rundownData.title) {
return null; return null;
+20 -1
View File
@@ -105,12 +105,29 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
handleCollapseGroup, handleCollapseGroup,
}); });
// Jump to the current element on demand: the running event in Run mode, the edit cursor otherwise.
// Scrolls only (no selection change) so jumping to the running event does not hijack the edit cursor.
const jumpToCurrent = useCallback(() => {
const targetId = editorMode === AppMode.Run ? featureData?.selectedEventId : cursor;
if (!targetId) {
return;
}
// Open parent group if the target is inside a collapsed group
const entry = entries[targetId];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
scrollToEntry(targetId);
}, [editorMode, featureData?.selectedEventId, cursor, entries, expandGroup, scrollToEntry]);
// Keyboard shortcuts // Keyboard shortcuts
useRundownKeyboard({ useRundownKeyboard({
cursor, cursor,
commands, commands,
clearSelectedEvents, clearSelectedEvents,
setEntryCopyId, setEntryCopyId,
jumpToCurrent,
}); });
// DND handlers // DND handlers
@@ -322,7 +339,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
increaseViewportBy={{ top: 200, bottom: 400 }} increaseViewportBy={{ top: 200, bottom: 400 }}
style={{ height: '100%' }} style={{ height: '100%' }}
components={{ components={{
Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined, Header: isEditMode
? () => <QuickAddButtons previousEventId={null} nextEventId={order[0]} parentGroup={null} />
: undefined,
Footer: () => ( Footer: () => (
<> <>
{isEditMode && ( {isEditMode && (
@@ -6,8 +6,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown'; import { RundownSelectionContextProvider } from '../../common/context/RundownSelectionContext';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
@@ -39,28 +38,29 @@ function RundownExport() {
defaultValue: RundownViewMode.List, defaultValue: RundownViewMode.List,
}); });
const isSmallDevice = useIsSmallDevice(); const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) { if (isSmallDevice && isExtracted) {
return ( return (
<EntryActionsProvider actions={entryActions}> <RundownSelectionContextProvider>
<ProtectRoute permission='editor'> <EntryActionsProvider>
<div <ProtectRoute permission='editor'>
className={cx([style.rundownExport, style.extracted])} <div
data-target='small-device' className={cx([style.rundownExport, style.extracted])}
data-testid='panel-rundown' data-target='small-device'
> data-testid='panel-rundown'
<FinderPlacement /> >
<ViewNavigationMenu suppressSettings /> <FinderPlacement />
<div className={style.rundown}> <ViewNavigationMenu suppressSettings />
<ErrorBoundary> <div className={style.rundown}>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} /> <ErrorBoundary>
<RundownContextMenu /> <RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
</ErrorBoundary> <RundownContextMenu />
</ErrorBoundary>
</div>
</div> </div>
</div> </ProtectRoute>
</ProtectRoute> </EntryActionsProvider>
</EntryActionsProvider> </RundownSelectionContextProvider>
); );
} }
@@ -70,30 +70,32 @@ function RundownExport() {
viewMode === RundownViewMode.Table; viewMode === RundownViewMode.Table;
return ( return (
<EntryActionsProvider actions={entryActions}> <RundownSelectionContextProvider>
<ProtectRoute permission='editor'> <EntryActionsProvider>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'> <ProtectRoute permission='editor'>
<FinderPlacement /> <div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />} <FinderPlacement />
<div className={style.rundown}> {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<Editor.Panel className={style.list}> <div className={style.rundown}>
<ErrorBoundary> <Editor.Panel className={style.list}>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary> <ErrorBoundary>
<RundownEntryEditor /> {!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary> </ErrorBoundary>
</div> </Editor.Panel>
)} {!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</div>
</div> </div>
</div> </ProtectRoute>
</ProtectRoute> </EntryActionsProvider>
</EntryActionsProvider> </RundownSelectionContextProvider>
); );
} }
@@ -105,17 +107,16 @@ interface RundownRootProps {
} }
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) { function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return ( return (
<div className={style.rundownRoot}> <div className={style.rundownRoot}>
{isSmallDevice ? ( {isSmallDevice ? (
<RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} /> <RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} />
) : ( ) : (
// TODO: add data-background-rundown={!isLoadedRundown} styling
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} /> <RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)} )}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />} {viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />} {viewMode === RundownViewMode.Table && <EntryEditModal />}
<RenumberCuesDialog /> <RenumberCuesDialog />
</div> </div>
); );
@@ -1,16 +1,23 @@
import { Playback } from 'ontime-types';
import { memo } from 'react'; import { memo } from 'react';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown'; import { useContextRundownList } from '../../common/hooks-query/useContextRundown';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import Rundown from './Rundown'; import Rundown from './Rundown';
const backgroundFeatureData = {
playback: Playback.Stop,
selectedEventId: null,
nextEventId: null,
};
export default memo(RundownList); export default memo(RundownList);
function RundownList() { function RundownList() {
const { data, status, rundownMetadata } = useRundownWithMetadata(); const { rundown, status, rundownMetadata, isLoadedRundown } = useContextRundownList();
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const isLoading = status !== 'success' || !data || !rundownMetadata; const isLoading = status !== 'success' || !rundown || !rundownMetadata;
if (isLoading) { if (isLoading) {
return <Empty text='Connecting to server' />; return <Empty text='Connecting to server' />;
@@ -18,12 +25,12 @@ function RundownList() {
return ( return (
<Rundown <Rundown
order={data.order} order={rundown.order}
flatOrder={data.flatOrder} flatOrder={rundown.flatOrder}
entries={data.entries} entries={rundown.entries}
id={data.id} id={rundown.id}
rundownMetadata={rundownMetadata} rundownMetadata={rundownMetadata}
featureData={featureData} featureData={isLoadedRundown ? featureData : backgroundFeatureData}
/> />
); );
} }
@@ -0,0 +1,3 @@
.rundownSelect {
min-width: min(20rem, calc(100vw - 6rem));
}
@@ -0,0 +1,42 @@
import Select from '../../../common/components/select/Select';
import { useRundownSelectionContext } from '../../../common/context/RundownSelectionContext';
import { AppMode } from '../../../ontimeConfig';
import styles from './RundownSelect.module.scss';
const FOLLOW = '___null___';
interface RundownSelectProps {
appMode: AppMode;
}
export function RundownSelect({ appMode }: RundownSelectProps) {
'use memo';
const { selectRundownId, rundowns, loadedRundownId, selectedRundownId } = useRundownSelectionContext();
const options = rundowns.map(({ id, title }) => ({
value: id,
label: loadedRundownId === id ? `${title} (loaded)` : title,
}));
// add a follow option
options.unshift({
value: FOLLOW,
label: 'Follow loaded',
});
return (
<div className={styles.rundownSelect}>
<Select
value={selectedRundownId ?? FOLLOW}
options={options}
onValueChange={(value) => {
if (value === FOLLOW) selectRundownId(null);
else selectRundownId(value);
}}
disabled={appMode === AppMode.Run}
fluid
/>
</div>
);
}
@@ -3,58 +3,94 @@
height: 100%; height: 100%;
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;
padding: 0.5rem; padding: 1rem 1.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-x: auto;
} }
.shortcutSection { .shortcutSection {
flex: 1; flex: 1;
margin-top: 15vh; width: min(100%, 48rem);
margin-top: clamp(1.5rem, 8vh, 5rem);
margin-inline: auto; margin-inline: auto;
gap: 1rem;
} }
.shortcuts { .shortcuts {
font-size: calc(1rem - 3px); display: grid;
border-collapse: separate; gap: 0.875rem;
border-spacing: 4rem 0; margin-top: 0.875rem;
}
tr { .shortcutGroup {
white-space: nowrap; h3 {
td:nth-child(odd) { margin: 0 0 0.375rem;
text-align: left; color: $ui-white;
} font-size: calc(1rem - 3px);
td:nth-child(even) { font-weight: 600;
text-align: right; text-transform: uppercase;
}
} }
} }
.spacer { .shortcutList {
height: 1rem; display: grid;
gap: 0.25rem;
}
.shortcutRow {
min-height: 1.625rem;
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
align-items: center;
gap: 0.75rem;
font-size: calc(1rem - 3px);
}
.shortcutLabel {
min-width: 0;
line-height: 1.2;
}
.shortcutKeys {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.keyCombo {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem 0;
}
.separator {
color: $gray-500;
font-size: calc(1rem - 5px);
} }
.prompt { .prompt {
margin-left: 4rem;
}
.divider {
display: inline-block;
text-align: center;
width: 1em;
}
.kbd {
font-family: monospace;
white-space: nowrap;
font-size: calc(1rem - 2px);
padding: 0.125rem 0.5rem;
background-color: $gray-1200;
color: $ui-white; color: $ui-white;
border-radius: 2px; }
font-weight: 400;
box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.4); @media (max-width: 680px) {
.entryEditor {
padding: 0.75rem 1rem;
}
.shortcutSection {
margin-top: 1rem;
}
.shortcutRow {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.shortcutKeys {
justify-content: flex-start;
}
} }
@@ -1,6 +1,7 @@
import { PropsWithChildren, memo } from 'react'; import { PropsWithChildren, memo } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Kbd from '../../../common/components/kbd/Kbd';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import style from './EventEditorEmpty.module.scss'; import style from './EventEditorEmpty.module.scss';
@@ -12,216 +13,127 @@ function EventEditorEmpty() {
<div className={style.entryEditor} data-testid='editor-container'> <div className={style.entryEditor} data-testid='editor-container'>
<div className={style.shortcutSection}> <div className={style.shortcutSection}>
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title> <Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
<table className={style.shortcuts}> <div className={style.shortcuts}>
<tbody> <ShortcutGroup title='Search'>
<tr> <Shortcut label='Find in rundown'>
<td>Find in rundown</td> <Combo keys={[deviceMod, 'F']} />
<td> </Shortcut>
<Kbd>{deviceMod}</Kbd> <Shortcut label='Open settings'>
<AuxKey>+</AuxKey> <Combo keys={[deviceMod, ',']} />
<Kbd>F</Kbd> </Shortcut>
</td> </ShortcutGroup>
</tr>
<tr> <ShortcutGroup title='Navigation'>
<td>Open Settings</td> <Shortcut label='Select entry'>
<td> <Combo keys={[deviceAlt, '↑']} />
<Kbd>{deviceMod}</Kbd> <Separator />
<AuxKey>+</AuxKey> <Combo keys={[deviceAlt, '↓']} />
<Kbd>,</Kbd> </Shortcut>
</td> <Shortcut label='Select group'>
</tr> <Combo keys={[deviceAlt, 'Shift', '↑']} />
<tr className={style.spacer} /> <Separator />
<tr> <Combo keys={[deviceAlt, 'Shift', '↓']} />
<td>Select entry</td> </Shortcut>
<td> <Shortcut label='Jump to top / bottom'>
<Kbd>{deviceAlt}</Kbd> <Combo keys={['Home']} />
<AuxKey>+</AuxKey> <Separator />
<Kbd></Kbd> <Combo keys={['End']} />
<AuxKey>/</AuxKey> </Shortcut>
<Kbd></Kbd> <Shortcut label='Page up / down'>
</td> <Combo keys={['PgUp']} />
</tr> <Separator />
<tr> <Combo keys={['PgDn']} />
<td>Select group</td> </Shortcut>
<td> <Shortcut label='Jump to current entry'>
<Kbd>{deviceAlt}</Kbd> <Combo keys={[deviceAlt, 'L']} />
<AuxKey>+</AuxKey> </Shortcut>
<Kbd>Shift</Kbd> <Shortcut label='Deselect entry'>
<AuxKey>+</AuxKey> <Combo keys={['Esc']} />
<Kbd></Kbd> </Shortcut>
<AuxKey>/</AuxKey> </ShortcutGroup>
<Kbd></Kbd>
</td> <ShortcutGroup title='Editing'>
</tr> <Shortcut label='Reorder selected entry'>
<tr> <Combo keys={[deviceAlt, deviceMod, '↑']} />
<td>Jump to top / bottom</td> <Separator />
<td> <Combo keys={[deviceAlt, deviceMod, '↓']} />
<Kbd>Home</Kbd> </Shortcut>
<AuxKey>/</AuxKey> <Shortcut label='Copy selected entry'>
<Kbd>End</Kbd> <Combo keys={[deviceMod, 'C']} />
</td> </Shortcut>
</tr> <Shortcut label='Cut selected entry'>
<tr> <Combo keys={[deviceMod, 'X']} />
<td>Page up / down</td> </Shortcut>
<td> <Shortcut label='Paste below'>
<Kbd>PgUp</Kbd> <Combo keys={[deviceMod, 'V']} />
<AuxKey>/</AuxKey> </Shortcut>
<Kbd>PgDn</Kbd> <Shortcut label='Paste above'>
</td> <Combo keys={[deviceMod, 'Shift', 'V']} />
</tr> </Shortcut>
<tr> <Shortcut label='Clone selected entry'>
<td>Deselect entry</td> <Combo keys={[deviceMod, 'D']} />
<td> </Shortcut>
<Kbd>Esc</Kbd> <Shortcut label='Delete selected entry'>
</td> <Combo keys={[deviceAlt, 'Backspace']} />
</tr> </Shortcut>
<tr className={style.spacer} /> </ShortcutGroup>
<tr>
<td>Reorder selected entry</td> <ShortcutGroup title='Insert'>
<td> <Shortcut label='Add event below / above'>
<Kbd>{deviceAlt}</Kbd> <Combo keys={[deviceAlt, 'E']} />
<AuxKey>+</AuxKey> <Separator />
<Kbd>{deviceMod}</Kbd> <Combo keys={[deviceAlt, 'Shift', 'E']} />
<AuxKey>+</AuxKey> </Shortcut>
<Kbd></Kbd> <Shortcut label='Add group below / above'>
<AuxKey>/</AuxKey> <Combo keys={[deviceAlt, 'G']} />
<Kbd></Kbd> <Separator />
</td> <Combo keys={[deviceAlt, 'Shift', 'G']} />
</tr> </Shortcut>
<tr> <Shortcut label='Add milestone below / above'>
<td>Copy selected entry</td> <Combo keys={[deviceAlt, 'M']} />
<td> <Separator />
<Kbd>{deviceMod}</Kbd> <Combo keys={[deviceAlt, 'Shift', 'M']} />
<AuxKey>+</AuxKey> </Shortcut>
<Kbd>C</Kbd> <Shortcut label='Add delay below / above'>
</td> <Combo keys={[deviceAlt, 'D']} />
</tr> <Separator />
<tr> <Combo keys={[deviceAlt, 'Shift', 'D']} />
<td>Cut selected entry</td> </Shortcut>
<td> </ShortcutGroup>
<Kbd>{deviceMod}</Kbd> </div>
<AuxKey>+</AuxKey>
<Kbd>X</Kbd>
</td>
</tr>
<tr>
<td>Paste above</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
</tr>
<tr>
<td>Paste below</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
</tr>
<tr>
<td>Clone selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Delete selected entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Backspace</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr>
<td>Add event below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
</tr>
<tr>
<td>Add event above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
</tr>
<tr>
<td>Add group below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>G</Kbd>
</td>
</tr>
<tr>
<td>Add group above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>G</Kbd>
</td>
</tr>
<tr>
<td>Add milestone below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>M</Kbd>
</td>
</tr>
<tr>
<td>Add milestone above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>M</Kbd>
</td>
</tr>
<tr>
<td>Add delay below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Add delay above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
); );
} }
function AuxKey({ children }: PropsWithChildren) { function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
return <span className={style.divider}>{children}</span>; return (
<section className={style.shortcutGroup}>
<h3>{title}</h3>
<div className={style.shortcutList}>{children}</div>
</section>
);
} }
function Kbd({ children }: PropsWithChildren) { function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
return <span className={style.kbd}>{children}</span>; return (
<div className={style.shortcutRow}>
<span className={style.shortcutLabel}>{label}</span>
<span className={style.shortcutKeys}>{children}</span>
</div>
);
}
function Combo({ keys }: { keys: string[] }) {
return (
<span className={style.keyCombo}>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
);
}
function Separator() {
return <span className={style.separator}>/</span>;
} }
@@ -1,7 +1,7 @@
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import useRundown from '../../../common/hooks-query/useRundown'; import { useRundown } from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
import EventEditorFooter from './composite/EventEditorFooter'; import EventEditorFooter from './composite/EventEditorFooter';
import EventEditor from './EventEditor'; import EventEditor from './EventEditor';
@@ -13,7 +13,7 @@ import style from './EntryEditor.module.scss';
export default function RundownEntryEditor() { export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown(); const { data } = useRundown(null);
const entry = useMemo<OntimeEntry | null>(() => { const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) { if (data.order.length === 0) {
@@ -10,7 +10,7 @@ import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { millisToDelayString } from '../../../../common/utils/dateConfig'; import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { formatTime } from '../../../../common/utils/time'; import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EntryEditor.module.scss'; import style from '../EntryEditor.module.scss';
@@ -68,8 +68,10 @@ function EventEditorTimes({
}; };
const hasDelay = delay !== 0; const hasDelay = delay !== 0;
const delayedStart = normaliseWallClock(timeStart + delay);
const delayedEnd = normaliseWallClock(timeEnd + delay);
const delayLabel = hasDelay const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(timeStart + delay)}${formatTime(timeEnd + delay)}` ? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)}${formatTime(delayedEnd)}`
: ''; : '';
return ( return (
@@ -86,7 +88,6 @@ function EventEditorTimes({
timeStrategy={timeStrategy} timeStrategy={timeStrategy}
linkStart={linkStart} linkStart={linkStart}
delay={delay} delay={delay}
countToEnd={countToEnd}
showLabels showLabels
/> />
</div> </div>
@@ -11,13 +11,17 @@ import style from './QuickAddButtons.module.scss';
interface QuickAddButtonsProps { interface QuickAddButtonsProps {
previousEventId: MaybeString; previousEventId: MaybeString;
nextEventId?: MaybeString;
parentGroup: MaybeString; parentGroup: MaybeString;
backgroundColor?: string; backgroundColor?: string;
} }
export default memo(QuickAddButtons); export default memo(QuickAddButtons);
function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) { function QuickAddButtons({ previousEventId, nextEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
const { addEntry } = useEntryActionsContext(); const { addEntry } = useEntryActionsContext();
const appendOptions = previousEventId ? { after: previousEventId, lastEventId: previousEventId } : undefined;
const prependOptions = nextEventId ? { before: nextEventId } : undefined;
const insertionOptions = appendOptions ?? prependOptions;
const addEvent = () => { const addEvent = () => {
addEntry( addEntry(
@@ -25,44 +29,23 @@ function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: Quic
type: SupportedEntry.Event, type: SupportedEntry.Event,
parent: parentGroup, parent: parentGroup,
}, },
{ insertionOptions,
after: previousEventId,
lastEventId: previousEventId,
},
); );
}; };
const addDelay = () => { const addDelay = () => {
addEntry( addEntry({ type: SupportedEntry.Delay, parent: parentGroup }, insertionOptions);
{ type: SupportedEntry.Delay, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
}; };
const addMilestone = () => { const addMilestone = () => {
addEntry( addEntry({ type: SupportedEntry.Milestone, parent: parentGroup }, insertionOptions);
{ type: SupportedEntry.Milestone, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
}; };
const addGroup = () => { const addGroup = () => {
if (parentGroup !== null) { if (parentGroup !== null) {
return; return;
} }
addEntry( addEntry({ type: SupportedEntry.Group }, insertionOptions);
{ type: SupportedEntry.Group },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
}; };
/** /**
@@ -96,7 +96,7 @@ export function useRundownCommands({
addEntry(patch, { addEntry(patch, {
after: id && !above ? id : undefined, after: id && !above ? id : undefined,
before: id && above ? id : undefined, before: id && above ? id : undefined,
lastEventId: !above && id ? id : undefined, lastEventId: id && !above ? id : undefined,
}); });
}, },
[addEntry], [addEntry],
@@ -19,6 +19,7 @@ interface UseRundownKeyboardOptions {
}; };
clearSelectedEvents: () => void; clearSelectedEvents: () => void;
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void; setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
jumpToCurrent: () => void;
} }
/** /**
@@ -40,6 +41,7 @@ export function useRundownKeyboard({
commands, commands,
clearSelectedEvents, clearSelectedEvents,
setEntryCopyId, setEntryCopyId,
jumpToCurrent,
}: UseRundownKeyboardOptions) { }: UseRundownKeyboardOptions) {
const scrollToEntry = useEventSelection((state) => state.scrollToEntry); const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
@@ -127,6 +129,8 @@ export function useRundownKeyboard({
{ preventDefault: true, usePhysicalKeys: true }, { preventDefault: true, usePhysicalKeys: true },
], ],
['alt + L', () => jumpToCurrent(), { preventDefault: true, usePhysicalKeys: true }],
[ [
'alt + mod + ArrowDown', 'alt + mod + ArrowDown',
() => commands.moveEntry(cursor, 'down'), () => commands.moveEntry(cursor, 'down'),
@@ -7,7 +7,7 @@ import Button from '../../../common/components/buttons/Button';
import Dialog from '../../../common/components/dialog/Dialog'; import Dialog from '../../../common/components/dialog/Dialog';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useRundown from '../../../common/hooks-query/useRundown'; import { useContextRundownCueRenumberModal } from '../../../common/hooks-query/useContextRundown';
import { orderEntries } from '../rundown.utils'; import { orderEntries } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -17,8 +17,7 @@ type RenumberCueData = Pick<RenumberCues, 'increment' | 'prefix' | 'start'>;
export default function RenumberCuesDialog() { export default function RenumberCuesDialog() {
'use memo'; 'use memo';
const { data } = useRundown(); const { flatOrder } = useContextRundownCueRenumberModal();
const { flatOrder } = data;
const { onClose, isOpen } = useRenumberCuesDialogStore(); const { onClose, isOpen } = useRenumberCuesDialogStore();
const { renumberCues } = useEntryActionsContext(); const { renumberCues } = useEntryActionsContext();
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
@@ -7,6 +7,7 @@ $skip-opacity: 0.2;
background-color: $block-bg; background-color: $block-bg;
margin-block: 0.25rem; margin-block: 0.25rem;
overflow: initial; overflow: initial;
position: relative;
display: grid; display: grid;
grid-template-areas: grid-template-areas:
@@ -16,7 +17,7 @@ $skip-opacity: 0.2;
'binder pb-actions estatus estatus' 'binder pb-actions estatus estatus'
'binder ... ... ...'; 'binder ... ... ...';
grid-template-columns: $block-binder-width 3rem 1fr 3rem; grid-template-columns: $block-binder-width 3rem minmax(0, 1fr) 3rem;
grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem; grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem;
align-items: center; align-items: center;
padding-right: $block-clearance; padding-right: $block-clearance;
@@ -136,6 +137,7 @@ $skip-opacity: 0.2;
align-items: center; align-items: center;
gap: $block-clearance; gap: $block-clearance;
height: 100%; height: 100%;
min-width: 0;
} }
.eventTimers.editMode:hover { .eventTimers.editMode:hover {
@@ -155,7 +157,7 @@ $skip-opacity: 0.2;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
.nextTag { .warningMeta {
font-size: 1rem; font-size: 1rem;
color: $orange-500; color: $orange-500;
letter-spacing: 0.03px; letter-spacing: 0.03px;
@@ -231,6 +233,10 @@ $skip-opacity: 0.2;
color: var(--status-color-active-override, $active-indicator); color: var(--status-color-active-override, $active-indicator);
} }
.statusIcon.countToEndStatus {
color: $orange-400;
}
.statusIcon.warning { .statusIcon.warning {
color: $orange-500; color: $orange-500;
} }
@@ -18,7 +18,7 @@ import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils'; import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog'; import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
import { useEventIdSwapping } from '../useEventIdSwapping'; import { useEventIdSwapping } from '../useEventIdSwapping';
@@ -156,7 +156,7 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Delete', label: 'Delete',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceMod}+Del`, shortcut: `${deviceAlt}+Backspace`,
onClick: () => { onClick: () => {
clearSelectedEvents(); clearSelectedEvents();
deleteEntry(Array.from(selectedEvents)); deleteEntry(Array.from(selectedEvents));
@@ -202,7 +202,7 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Delete', label: 'Delete',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceMod}+Del`, shortcut: `${deviceAlt}+Backspace`,
onClick: () => { onClick: () => {
deleteEntry([eventId]); deleteEntry([eventId]);
unselect(eventId); unselect(eventId);
@@ -115,12 +115,12 @@ function RundownEventInner({
delay={delay} delay={delay}
timeStrategy={timeStrategy} timeStrategy={timeStrategy}
linkStart={linkStart} linkStart={linkStart}
countToEnd={countToEnd}
/> />
</div> </div>
<div className={style.titleSection}> <div className={style.titleSection}>
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} /> <TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
{isNext && <span className={style.nextTag}>UP NEXT</span>} {isNext && <span className={style.warningMeta}>UP NEXT</span>}
{!isNext && countToEnd && <span className={style.warningMeta}>COUNT TO END</span>}
</div> </div>
<EventBlockPlayback <EventBlockPlayback
eventId={eventId} eventId={eventId}
@@ -157,7 +157,13 @@ function RundownEventInner({
<EndActionIcon action={endAction} className={style.statusIcon} /> <EndActionIcon action={endAction} className={style.statusIcon} />
</Tooltip> </Tooltip>
<Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}> <Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}>
<LuArrowDownToLine className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} /> <LuArrowDownToLine
className={cx([
style.statusIcon,
countToEnd ? style.active : style.disabled,
countToEnd && style.countToEndStatus,
])}
/>
</Tooltip> </Tooltip>
<Tooltip text={automationTooltip} render={<span />}> <Tooltip text={automationTooltip} render={<span />}>
<IoFlash className={automationIconClasses} /> <IoFlash className={automationIconClasses} />
@@ -1,10 +1,33 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { formatDelay } from '../rundownEvent.utils'; import { formatDelay } from '../rundownEvent.utils';
describe('formatDelay()', () => { describe('formatDelay()', () => {
it('adds a given delay to the start time', () => { it('adds a given delay to the start time', () => {
const timeStart = 60000; // 1 min const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
const delay = 60000; // 1 min const delay = 1 * MILLIS_PER_MINUTE; // 00:01
const result = formatDelay(timeStart, delay); const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 00:02'); expect(result).toEqual('New start 00:02');
}); });
it('wraps negative delayed starts under midnight', () => {
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
const delay = -2 * MILLIS_PER_MINUTE; // -00:02
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 23:59');
});
it('wraps later-day negative delays using delay as the source of truth', () => {
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
const delay = -(1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE); // -01:30
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 23:30');
});
it('displays positive delays as wall-clock time', () => {
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
const delay = 1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE; // 01:30
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 02:30');
});
}); });
@@ -3,7 +3,5 @@
width: 0; width: 0;
border-radius: 1px 0 0 1px; border-radius: 1px 0 0 1px;
transition: 1s linear;
transition-property: width;
background-color: $gray-200; background-color: $gray-200;
} }
@@ -1,12 +1,12 @@
import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress';
import { useTimer } from '../../../../common/hooks/useSocket'; import { useTimer } from '../../../../common/hooks/useSocket';
import { getProgress } from '../../../../common/utils/getProgress';
import style from './RundownEventProgressBar.module.scss'; import style from './RundownEventProgressBar.module.scss';
export default function RundownEventProgressBar() { export default function RundownEventProgressBar() {
const timer = useTimer(); const timer = useTimer();
const progress = getProgress(timer.current, timer.duration); const progress = useAnimatedProgress(timer.current, timer.duration);
return <div className={style.progressBar} style={{ width: `${progress}%` }} />; return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
} }
@@ -1,15 +1,16 @@
import { millisToString, removeTrailingZero } from 'ontime-utils'; import { millisToString, removeTrailingZero } from 'ontime-utils';
import { formatDuration } from '../../../common/utils/time'; import { formatDuration, normaliseWallClock } from '../../../common/utils/time';
export function formatDelay(timeStart: number, delay: number): string | undefined { export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return; if (!delay) return;
const delayedStart = Math.max(0, timeStart + delay); const delayedStart = normaliseWallClock(timeStart + delay);
const timeTag = removeTrailingZero(millisToString(delayedStart)); const timeTag = removeTrailingZero(millisToString(delayedStart));
return `New start ${timeTag}`; return `New start ${timeTag}`;
} }
export function formatGap(gap: number, isNextDay: boolean) { export function formatGap(gap: number, isNextDay: boolean) {
if (gap === 0) { if (gap === 0) {
if (isNextDay) { if (isNextDay) {
@@ -82,6 +82,17 @@
margin-right: 0.25rem; margin-right: 0.25rem;
color: $ui-white; color: $ui-white;
} }
.duration {
display: flex;
align-items: center;
gap: 0.25rem;
}
.lockIcon {
opacity: 0.6;
}
.over { .over {
color: $playback-over; color: $playback-over;
.strike { .strike {
@@ -8,6 +8,7 @@ import {
IoChevronUp, IoChevronUp,
IoDuplicateOutline, IoDuplicateOutline,
IoFolderOpenOutline, IoFolderOpenOutline,
IoLockClosed,
IoReorderTwo, IoReorderTwo,
IoTrash, IoTrash,
} from 'react-icons/io5'; } from 'react-icons/io5';
@@ -17,7 +18,7 @@ import Tag from '../../../common/components/tag/Tag';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils'; import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import { getOffsetState } from '../../../common/utils/offset'; import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time'; import { formatDuration, formatTime } from '../../../common/utils/time';
@@ -65,7 +66,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
type: 'item', type: 'item',
label: 'Delete Group', label: 'Delete Group',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceMod}+Del`, shortcut: `${deviceAlt}+Backspace`,
onClick: () => deleteEntry([data.id]), onClick: () => deleteEntry([data.id]),
}, },
]); ]);
@@ -176,14 +177,17 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
</div> </div>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div className={style.metaLabel}>Duration</div> <div className={style.metaLabel}>Duration</div>
{planOffset === null ? ( <div className={style.duration}>
<div>{formatDuration(data.duration)}</div> {planOffset === null ? (
) : ( formatDuration(data.duration)
<div className={cx([planOffsetLabel && style[planOffsetLabel]])}> ) : (
<span className={style.strike}>{formatDuration(data.duration)}</span> <span className={cx([planOffsetLabel && style[planOffsetLabel]])}>
<Tag className={style.offsetLabel}>{planOffset}</Tag> <span className={style.strike}>{formatDuration(data.duration)}</span>
</div> <Tag className={style.offsetLabel}>{planOffset}</Tag>
)} </span>
)}
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -8,6 +8,7 @@ import Tooltip from '../../../common/components/tooltip/Tooltip';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket'; import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout'; import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout';
import { RundownSelect } from '../common/RundownSelect';
import { RundownViewMode } from '../rundown.options'; import { RundownViewMode } from '../rundown.options';
import { useEditorFollowMode } from '../useEditorFollowMode'; import { useEditorFollowMode } from '../useEditorFollowMode';
import RundownMenu from './RundownMenu'; import RundownMenu from './RundownMenu';
@@ -24,6 +25,7 @@ interface HeaderControlsConfig {
showRunEditToggle: boolean; showRunEditToggle: boolean;
showOffsetToggle: boolean; showOffsetToggle: boolean;
showOverflowMenu: boolean; showOverflowMenu: boolean;
showRundownSelect: boolean;
} }
export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = { export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = {
@@ -31,16 +33,19 @@ export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConf
showRunEditToggle: true, showRunEditToggle: true,
showOffsetToggle: true, showOffsetToggle: true,
showOverflowMenu: true, showOverflowMenu: true,
showRundownSelect: false,
}, },
[EditorLayoutMode.PLANNING]: { [EditorLayoutMode.PLANNING]: {
showRunEditToggle: false, showRunEditToggle: false,
showOffsetToggle: false, showOffsetToggle: false,
showOverflowMenu: true, showOverflowMenu: true,
showRundownSelect: true,
}, },
[EditorLayoutMode.TRACKING]: { [EditorLayoutMode.TRACKING]: {
showRunEditToggle: false, showRunEditToggle: false,
showOffsetToggle: true, showOffsetToggle: true,
showOverflowMenu: false, showOverflowMenu: false,
showRundownSelect: false,
}, },
} as const; } as const;
@@ -50,7 +55,8 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
const offsetMode = useOffsetMode(); const offsetMode = useOffsetMode();
const { layoutMode } = useEditorLayout(); const { layoutMode } = useEditorLayout();
const { showRunEditToggle, showOffsetToggle, showOverflowMenu } = HEADER_CONTROLS_CONFIG[layoutMode]; const { showRunEditToggle, showOffsetToggle, showOverflowMenu, showRundownSelect } =
HEADER_CONTROLS_CONFIG[layoutMode];
const toggleAppMode = (mode: AppMode[]) => { const toggleAppMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode // we need to stop user from deselecting a mode
@@ -122,6 +128,8 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
</ToggleGroup> </ToggleGroup>
)} )}
{showRundownSelect && <RundownSelect appMode={editorMode} />}
{showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />} {showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />}
</Toolbar.Root> </Toolbar.Root>
); );
@@ -9,7 +9,7 @@ import useReactiveTextInput from '../../../common/components/input/text-input/us
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils'; import { deviceAlt } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -38,7 +38,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
type: 'item', type: 'item',
label: 'Delete', label: 'Delete',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceMod}+Del`, shortcut: `${deviceAlt}+Backspace`,
onClick: () => deleteEntry([entryId]), onClick: () => deleteEntry([entryId]),
}, },
]); ]);
@@ -1,10 +1,7 @@
import { memo, useEffect, useMemo } from 'react'; import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage'; import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable'; import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions'; import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -16,8 +13,6 @@ function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode(); const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions // Editor always has full permissions
useEffect(() => { useEffect(() => {
@@ -35,14 +30,12 @@ function RundownTable() {
const isLoading = !customFields || customFieldStatus === 'pending'; const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<EntryActionsProvider actions={actions}> <CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetDnd columns={columns} tableRoot='editor'> {isLoading ? (
{isLoading ? ( <EmptyPage text='Loading...' />
<EmptyPage text='Loading...' /> ) : (
) : ( <CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' /> )}
)} </CuesheetDnd>
</CuesheetDnd>
</EntryActionsProvider>
); );
} }
@@ -4,10 +4,15 @@
.timerNote { .timerNote {
width: 1.25em; width: 1.25em;
flex: 0 0 1.25em;
color: $blue-500; color: $blue-500;
font-size: 1.5em; font-size: 1.5em;
} }
.timerNotePlaceholder {
visibility: hidden;
}
.inactive { .inactive {
color: $muted-gray; color: $muted-gray;
} }
@@ -14,7 +14,6 @@ import style from './TimeInputFlow.module.scss';
interface TimeInputFlowProps { interface TimeInputFlowProps {
eventId: string; eventId: string;
countToEnd: boolean;
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
duration: number; duration: number;
@@ -22,12 +21,12 @@ interface TimeInputFlowProps {
linkStart: boolean; linkStart: boolean;
delay: number; delay: number;
showLabels?: boolean; showLabels?: boolean;
showWarnings?: boolean;
} }
export default memo(TimeInputFlow); export default memo(TimeInputFlow);
function TimeInputFlow({ function TimeInputFlow({
eventId, eventId,
countToEnd,
timeStart, timeStart,
timeEnd, timeEnd,
duration, duration,
@@ -35,6 +34,7 @@ function TimeInputFlow({
linkStart, linkStart,
delay, delay,
showLabels, showLabels,
showWarnings = true,
}: TimeInputFlowProps) { }: TimeInputFlowProps) {
const { updateEntry, updateTimer } = useEntryActionsContext(); const { updateEntry, updateTimer } = useEntryActionsContext();
@@ -56,10 +56,6 @@ function TimeInputFlow({
warnings.push('Over midnight'); warnings.push('Over midnight');
} }
if (countToEnd) {
warnings.push('Count to End');
}
const hasDelay = delay !== 0; const hasDelay = delay !== 0;
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd; const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration; const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
@@ -136,11 +132,19 @@ function TimeInputFlow({
</TimeInputGroup> </TimeInputGroup>
</div> </div>
{warnings.length > 0 && ( {showWarnings &&
<Tooltip text={warnings.join(' - ')} className={style.timerNote} data-testid='event-warning' render={<span />}> (warnings.length > 0 ? (
<IoAlertCircleOutline /> <Tooltip
</Tooltip> text={warnings.join(' - ')}
)} className={style.timerNote}
data-testid='event-warning'
render={<span />}
>
<IoAlertCircleOutline />
</Tooltip>
) : (
<span className={`${style.timerNote} ${style.timerNotePlaceholder}`} aria-hidden='true' />
))}
</> </>
); );
} }

Some files were not shown because too many files have changed in this diff Show More