Compare commits

...

49 Commits

Author SHA1 Message Date
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
Carlos Valente 42147d4d5b allow editing cuesheet link permissions 2026-07-01 13:24:47 +02:00
Carlos Valente 7850358039 bump ontime to version 4.10.0 2026-07-01 13:20:04 +02:00
Claude 52cae1e22f feat(timeline): add group reference
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2026-07-01 12:48:58 +02:00
Carlos Valente 48f9fb2609 expose mcp server in ontime 2026-07-01 12:48:40 +02:00
Carlos Valente ce8a4d230b refactor(translations): allow resetting to default values 2026-07-01 09:02:13 +02:00
Carlos Valente 1553573b94 feat(url-presets): allow presets to appear as nav menu items (#2110) 2026-06-28 18:07:02 +02:00
Alex Christoffer Rasmussen 41a09bf5c3 fix: send refetch on all rundown edits (#2098)
* fix(server): send refetch on all rundown edits

* fix(test): cuesheet tabing

* chore: remove unneeded async

* refactor: small cleanups

* chore: spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* chore: remove unneded async/await

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-06-20 12:21:11 +02:00
Claude 5dbe7afae8 feat(client): add rundown default to inherit parent group colour 2026-06-20 10:46:34 +02:00
Carlos Valente 6633cfc869 chore: minor server package upgrades 2026-06-20 09:15:23 +02:00
Carlos Valente d127367df9 chore: minor client package upgrades 2026-06-20 09:15:23 +02:00
Carlos Valente b21ec41ffd chore: upgrade base-ui 2026-06-20 09:15:23 +02:00
Carlos Valente 66292bc0cf chore: upgrade react packages 2026-06-20 09:15:23 +02:00
Alex Christoffer Rasmussen b946b245c2 feat: create the first event from the cuesheet (#2105)
* feat: create the first event from the cuesheet

* chore: cleanup css

* test: cuesheet beckground edit from empty state

* feat: only show +event button when user is in edit mode and have full write perms
2026-06-17 22:31:13 +02:00
Carlos Valente c238147b75 chore: bump node version 2026-06-11 18:58:00 +02:00
194 changed files with 9001 additions and 1880 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
+3
View File
@@ -54,3 +54,6 @@ ontime-data/
# temporary write files # temporary write files
**.tmp **.tmp
# Claude Code metadata
.claude/
+1 -1
View File
@@ -1 +1 @@
v22.22.2 v22.22.3
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.9.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",
+16 -16
View File
@@ -1,10 +1,10 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.9.0", "version": "4.11.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui/react": "1.3.0", "@base-ui/react": "1.6.0",
"@codemirror/commands": "^6.0.0", "@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0", "@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -12,27 +12,27 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.6", "@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^8.3.7", "@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.2.0", "@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.85.9", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.85.9", "@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-vscode": "^4.25.9", "@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.12.2", "axios": "^1.18.0",
"csv-stringify": "^6.6.0", "csv-stringify": "^6.6.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.3", "react": "^19.2.7",
"react-colorful": "^5.6.1", "react-colorful": "^5.7.0",
"react-dom": "^19.2.3", "react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.72.0", "react-hook-form": "^7.80.0",
"react-icons": "5.5.0", "react-icons": "5.6.0",
"react-router": "^7.11.0", "react-router": "^8.0.1",
"react-virtuoso": "^4.17.0", "react-virtuoso": "^4.18.7",
"zustand": "^5.0.9" "zustand": "^5.0.14"
}, },
"scripts": { "scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
+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;
@@ -8,6 +8,7 @@ import { useLocation } from 'react-router';
import { isLocalhost, supportsFullscreen } from '../../../externals'; import { isLocalhost, supportsFullscreen } from '../../../externals';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock'; import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore'; import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
@@ -105,6 +106,8 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
{route.label} {route.label}
</ClientLink> </ClientLink>
))} ))}
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div> </div>
{isLocalhost && ( {isLocalhost && (
@@ -117,3 +120,27 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
</Dialog.Root> </Dialog.Root>
); );
} }
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation();
const { data: urlPresets } = useUrlPresets();
const navPresets = urlPresets.filter((preset) => preset.enabled && preset.displayInNav);
if (navPresets.length === 0) return null;
return (
<>
<hr className={style.separator} />
{navPresets.map((preset) => (
<ClientLink
key={preset.alias}
to={`preset/${preset.alias}`}
current={location.pathname === `/preset/${preset.alias}`}
postAction={isSmallScreen ? onClose : undefined}
>
{preset.alias}
</ClientLink>
))}
</>
);
}
@@ -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);
} }
} }
@@ -8,9 +8,12 @@
padding-top: 10vh; padding-top: 10vh;
} }
.empty { .inline {
width: 100%; display: flex;
opacity: 0.8; align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1em;
} }
.text { .text {
@@ -1,18 +1,33 @@
import EmptyImage from '../../../assets/images/empty.svg?react'; import { SupportedEntry } from 'ontime-types';
import { IoAdd } from 'react-icons/io5';
import { useTranslation } from '../../../translation/TranslationProvider';
import Button from '../buttons/Button';
import Empty from './Empty';
import style from './EmptyTableBody.module.scss'; import style from './EmptyTableBody.module.scss';
interface EmptyTableBodyProps { interface EmptyTableBodyProps {
text: string; handleAddNew?: (type: SupportedEntry) => void;
} }
export default function EmptyTableBody({ text }: EmptyTableBodyProps) { export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
const { getLocalizedString } = useTranslation();
const text = getLocalizedString('common.no_data');
return ( return (
<tbody className={style.emptyContainer}> <tbody className={style.emptyContainer}>
<tr> <tr>
<td colSpan={99} className={style.emptyCell}> <td colSpan={99} className={style.emptyCell}>
<EmptyImage className={style.empty} /> <Empty injectedStyles={{ marginTop: '5vh' }} />
{text && <span className={style.text}>{text}</span>} <span className={style.text}>{text}</span>
{handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
<IoAdd />
Create Event
</Button>
</div>
)}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -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());
}; };
@@ -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);
}
+24 -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,
@@ -16,6 +15,7 @@ import {
TimeStrategy, TimeStrategy,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
isOntimeMilestone,
} from 'ontime-types'; } from 'ontime-types';
import { import {
MILLIS_PER_SECOND, MILLIS_PER_SECOND,
@@ -59,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.
*/ */
@@ -82,6 +88,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
defaultDangerTime, defaultDangerTime,
defaultTimerType, defaultTimerType,
defaultEndAction, defaultEndAction,
inheritGroupColour,
} = useEditorSettings(); } = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => { const resolveCurrentRundownQueryKey = useCallback(() => {
@@ -118,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 });
@@ -147,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);
@@ -191,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) {
@@ -203,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;
@@ -239,6 +247,14 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
} }
} }
if (inheritGroupColour && (isOntimeEvent(newEntry) || isOntimeMilestone(newEntry)) && !newEntry.colour) {
const parentId = resolveInsertParent(rundownData, newEntry);
const maybeParent = parentId ? rundownData.entries[parentId] : null;
if (maybeParent && isOntimeGroup(maybeParent)) {
newEntry.colour = maybeParent.colour;
}
}
try { try {
await addEntryMutation([rundownId, newEntry]); await addEntryMutation([rundownId, newEntry]);
} catch (error) { } catch (error) {
@@ -254,6 +270,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
defaultTimerType, defaultTimerType,
defaultEndAction, defaultEndAction,
defaultTimeStrategy, defaultTimeStrategy,
inheritGroupColour,
addEntryMutation, addEntryMutation,
], ],
); );
@@ -273,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) {
@@ -1046,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);
});
});
@@ -12,8 +12,10 @@ type EditorSettingsStore = {
defaultDangerTime: string; defaultDangerTime: string;
defaultTimerType: TimerType; defaultTimerType: TimerType;
defaultEndAction: EndAction; defaultEndAction: EndAction;
inheritGroupColour: boolean;
setDefaultDuration: (defaultDuration: string) => void; setDefaultDuration: (defaultDuration: string) => void;
setLinkPrevious: (linkPrevious: boolean) => void; setLinkPrevious: (linkPrevious: boolean) => void;
setInheritGroupColour: (inheritGroupColour: boolean) => void;
setTimeStrategy: (timeStrategy: TimeStrategy) => void; setTimeStrategy: (timeStrategy: TimeStrategy) => void;
setWarnTime: (warnTime: string) => void; setWarnTime: (warnTime: string) => void;
setDangerTime: (dangerTime: string) => void; setDangerTime: (dangerTime: string) => void;
@@ -29,6 +31,7 @@ export const editorSettingsDefaults = {
dangerTime: '00:01:00', // 60000 same as backend dangerTime: '00:01:00', // 60000 same as backend
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
endAction: EndAction.None, endAction: EndAction.None,
inheritGroupColour: false,
}; };
enum EditorSettingsKeys { enum EditorSettingsKeys {
@@ -39,6 +42,7 @@ enum EditorSettingsKeys {
DefaultDangerTime = 'ontime-default-danger-time', DefaultDangerTime = 'ontime-default-danger-time',
DefaultTimerType = 'ontime-default-timer-type', DefaultTimerType = 'ontime-default-timer-type',
DefaultEndAction = 'ontime-default-end-action', DefaultEndAction = 'ontime-default-end-action',
InheritGroupColour = 'ontime-inherit-group-colour',
} }
export const useEditorSettings = create<EditorSettingsStore>((set) => { export const useEditorSettings = create<EditorSettingsStore>((set) => {
@@ -59,6 +63,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.getItem(EditorSettingsKeys.DefaultEndAction), localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
editorSettingsDefaults.endAction, editorSettingsDefaults.endAction,
), ),
inheritGroupColour: booleanFromLocalStorage(
EditorSettingsKeys.InheritGroupColour,
editorSettingsDefaults.inheritGroupColour,
),
setDefaultDuration: (defaultDuration) => setDefaultDuration: (defaultDuration) =>
set(() => { set(() => {
@@ -97,5 +105,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction)); localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
return { defaultEndAction }; return { defaultEndAction };
}), }),
setInheritGroupColour: (inheritGroupColour) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
return { inheritGroupColour };
}),
}; };
}); });
@@ -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');
});
});
@@ -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('');
});
});
@@ -37,6 +37,16 @@ describe('getRouteFromPreset()', () => {
alias: 'demopage', alias: 'demopage',
target: OntimeView.Timer, target: OntimeView.Timer,
search: 'user=guest', search: 'user=guest',
displayInNav: false,
options: {},
},
];
const disabledPresets: URLPreset[] = [
{
enabled: false,
alias: 'demopage',
target: OntimeView.Timer,
search: 'user=guest',
options: {}, options: {},
}, },
]; ];
@@ -47,6 +57,16 @@ describe('getRouteFromPreset()', () => {
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage'); expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
}); });
it('checks if the current location matches an enabled preset target', () => {
const location = resolvePath('/timer');
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
});
it('does not redirect disabled presets', () => {
const location = resolvePath('/demopage');
expect(getRouteFromPreset(location, disabledPresets)).toBeNull();
});
it('returns null when already on a preset path', () => { it('returns null when already on a preset path', () => {
const location = resolvePath('/preset/demopage'); const location = resolvePath('/preset/demopage');
expect(getRouteFromPreset(location, presets)).toBeNull(); expect(getRouteFromPreset(location, presets)).toBeNull();
@@ -86,6 +106,11 @@ describe('getRouteFromPreset()', () => {
const location = resolvePath('/demopage?n=1&token=123'); const location = resolvePath('/demopage?n=1&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123'); expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
}); });
it('redirects stale unwrapped params back to the saved preset params while preserving feature params', () => {
const location = resolvePath('/timer?user=admin&alias=demopage&n=1&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
});
}); });
describe('cuesheet presets', () => { describe('cuesheet presets', () => {
@@ -95,6 +120,7 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-4685d6', alias: 'cuesheet-4685d6',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: '-', write: '-',
@@ -107,6 +133,7 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-basic', alias: 'cuesheet-basic',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
}, },
]; ];
const cuesheetPresetWithNavLock: URLPreset[] = [ const cuesheetPresetWithNavLock: URLPreset[] = [
@@ -115,6 +142,7 @@ describe('getRouteFromPreset()', () => {
alias: 'cuesheet-locked', alias: 'cuesheet-locked',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: 'n=1', search: 'n=1',
displayInNav: false,
}, },
]; ];
@@ -196,6 +224,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -207,6 +236,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -218,6 +248,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
[ [
@@ -229,6 +260,7 @@ describe('generateUrlPresetOptions', () => {
target: 'timer', target: 'timer',
search: 'param1=value1&param2=value2', search: 'param1=value1&param2=value2',
enabled: true, enabled: true,
displayInNav: false,
}, },
], ],
])('should generate URL preset options for %s', (_description, alias, url, expected) => { ])('should generate URL preset options for %s', (_description, alias, url, expected) => {
+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}`;
}
+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');
} }
@@ -194,6 +194,7 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre
target: path, target: path,
search: url.searchParams.toString(), search: url.searchParams.toString(),
enabled: true, enabled: true,
displayInNav: false,
}; };
} }
@@ -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,
};
}
@@ -1,7 +1,8 @@
import { URLPreset } from 'ontime-types'; import { OntimeView, URLPreset } from 'ontime-types';
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5'; import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
@@ -22,13 +23,23 @@ const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
export default function URLPresets() { export default function URLPresets() {
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined }); const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
const [actionError, setActionError] = useState<string | null>(null);
const { data, status } = useUrlPresets(); const { data, status } = useUrlPresets();
const { deletePreset, isMutating } = useUpdateUrlPreset(); const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset();
const openNewForm = () => setFormState({ isOpen: true }); const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset }); const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
const closeForm = () => setFormState({ isOpen: false, preset: undefined }); const closeForm = () => setFormState({ isOpen: false, preset: undefined });
const persistPreset = async (preset: URLPreset) => {
setActionError(null);
try {
await updatePreset(preset.alias, preset);
} catch (error) {
setActionError(maybeAxiosError(error));
}
};
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
@@ -53,10 +64,12 @@ export default function URLPresets() {
<Panel.Section> <Panel.Section>
<Panel.Loader isLoading={status === 'pending'} /> <Panel.Loader isLoading={status === 'pending'} />
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />} {formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
{actionError && <Panel.Error>{actionError}</Panel.Error>}
<Panel.Table> <Panel.Table>
<thead> <thead>
<tr> <tr>
<th>Enabled</th> <th>Enabled</th>
<th>Show in nav</th>
<th>Target view</th> <th>Target view</th>
<th>Alias</th> <th>Alias</th>
<th /> <th />
@@ -65,10 +78,23 @@ export default function URLPresets() {
<tbody> <tbody>
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />} {data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
{data.map((preset, index) => { {data.map((preset, index) => {
const isCuesheet = preset.target === OntimeView.Cuesheet;
return ( return (
<tr key={preset.alias}> <tr key={preset.alias}>
<td> <td>
<Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} /> <Switch
checked={preset.enabled}
onCheckedChange={(enabled) => persistPreset({ ...preset, enabled })}
disabled={isMutating}
aria-label='Toggle preset enabled'
/>
</td>
<td>
<Switch
checked={preset.displayInNav}
onCheckedChange={(checked) => persistPreset({ ...preset, displayInNav: checked })}
disabled={isMutating || isCuesheet}
/>
</td> </td>
<td> <td>
<Tag>{preset.target}</Tag> <Tag>{preset.target}</Tag>
@@ -1,5 +1,5 @@
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types'; import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils'; import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
@@ -12,6 +12,7 @@ import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex'; import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils'; import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets'; import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './URLPresetForm.module.scss'; import style from './URLPresetForm.module.scss';
@@ -32,6 +33,7 @@ const defaultValues: URLPreset = {
target: OntimeView.Timer, target: OntimeView.Timer,
search: '', search: '',
enabled: true, enabled: true,
displayInNav: false,
}; };
interface URLPresetFormProps { interface URLPresetFormProps {
@@ -61,12 +63,42 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
}); });
const urlRef = useRef<HTMLInputElement>(null); const urlRef = useRef<HTMLInputElement>(null);
// Cuesheet read/write permissions live outside react-hook-form
const initialPermissions = useRef<CuesheetPermissionValues>({
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
});
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>(initialPermissions.current);
// update initial permissions on mount
useEffect(() => {
initialPermissions.current = {
read: urlPreset?.options?.read ?? 'full',
write: urlPreset?.options?.write ?? 'full',
};
setCuesheetPermissions(initialPermissions.current);
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps -- run on mount
}, []);
const isEditingCuesheet = urlPreset && urlPreset.target === OntimeView.Cuesheet;
const isCuesheet = watch('target') === OntimeView.Cuesheet;
const permissionsDirty =
isCuesheet &&
(cuesheetPermissions.read !== initialPermissions.current.read ||
cuesheetPermissions.write !== initialPermissions.current.write);
const noReadAccess = isCuesheet && cuesheetPermissions.read === '-';
const setupSubmit = async (data: URLPreset) => { const setupSubmit = async (data: URLPreset) => {
try { try {
// Preserve / apply cuesheet permissions, which are not part of the form fields
const payload: URLPreset =
data.target === OntimeView.Cuesheet
? { ...data, target: OntimeView.Cuesheet, options: cuesheetPermissions }
: data;
if (urlPreset) { if (urlPreset) {
await updatePreset(urlPreset.alias, data); await updatePreset(urlPreset.alias, payload);
} else { } else {
await addPreset(data); await addPreset(payload);
} }
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -74,6 +106,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
} }
}; };
// focus on alias when the form opens
useEffect(() => { useEffect(() => {
setFocus('alias'); setFocus('alias');
}, [setFocus]); }, [setFocus]);
@@ -126,44 +159,66 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
<div className={style.expand}> <div className={style.expand}>
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description> <Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
<Panel.InlineElements> <Panel.InlineElements>
<Input placeholder='Paste URL' fluid ref={urlRef} /> <Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
<Button onClick={generateOptions}>Generate</Button> <Button onClick={generateOptions} disabled={isEditingCuesheet}>
Generate
</Button>
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
</Panel.InlineElements> </Panel.InlineElements>
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>} {errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
<div> {!isEditingCuesheet && (
{enDash} or {enDash} <>
</div> <div>
<div>2. Choose a view and its parameters</div> {enDash} or {enDash}
<div> </div>
<Panel.Description>Target</Panel.Description> <div>2. Choose a view and its parameters</div>
<Select <div>
options={targetOptions} <Panel.Description>Target</Panel.Description>
{...register('target', { required: 'Target is required' })} <Select
value={watch('target')} options={targetOptions}
onValueChange={(value: OntimeViewPresettable | null) => { {...register('target', { required: 'Target is required' })}
if (value === null) return; value={watch('target')}
setValue('target', value, { shouldDirty: true }); onValueChange={(value: OntimeViewPresettable | null) => {
}} if (value === null) return;
/> setValue('target', value, { shouldDirty: true });
</div> }}
<div> />
<Panel.Description>Parameters</Panel.Description> </div>
<Textarea <div>
fluid <Panel.Description>Parameters</Panel.Description>
rows={3} <Textarea
{...register('search', { fluid
validate: validateParams, rows={3}
})} {...register('search', {
/> validate: validateParams,
<Panel.Error>{errors.search?.message}</Panel.Error> })}
</div> />
<Panel.Error>{errors.search?.message}</Panel.Error>
</div>
</>
)}
{isCuesheet && (
<div>
<Panel.Description>Permissions</Panel.Description>
<CuesheetLinkOptions
initialRead={initialPermissions.current.read}
initialWrite={initialPermissions.current.write}
onChange={setCuesheetPermissions}
/>
</div>
)}
<div> <div>
<Panel.Error>{errors.root?.message}</Panel.Error> <Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end'> <Panel.InlineElements align='end'>
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}> <Button
variant='primary'
type='submit'
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
loading={isSubmitting || isMutating}
>
Save Save
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
@@ -157,7 +157,7 @@ export default function ManageRundowns() {
</td> </td>
<td> <td>
<DropdownMenu <DropdownMenu
render={<IconButton variant='ghosted-white' />} render={<IconButton variant='ghosted-white' data-testId='rundown_menu' />}
items={[ items={[
{ {
type: 'item', type: 'item',
@@ -16,6 +16,7 @@ export default function RundownDefaultSettings() {
defaultDangerTime, defaultDangerTime,
defaultTimerType, defaultTimerType,
defaultEndAction, defaultEndAction,
inheritGroupColour,
setDefaultDuration, setDefaultDuration,
setLinkPrevious, setLinkPrevious,
setTimeStrategy, setTimeStrategy,
@@ -23,6 +24,7 @@ export default function RundownDefaultSettings() {
setDangerTime, setDangerTime,
setDefaultTimerType, setDefaultTimerType,
setDefaultEndAction, setDefaultEndAction,
setInheritGroupColour,
} = useEditorSettings((state) => state); } = useEditorSettings((state) => state);
const durationInMs = parseUserTime(defaultDuration); const durationInMs = parseUserTime(defaultDuration);
@@ -44,6 +46,13 @@ export default function RundownDefaultSettings() {
/> />
<Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} /> <Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} />
</Panel.ListItem> </Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Inherit group colour'
description='Whether new events and milestones inherit the colour of their parent group'
/>
<Switch size='large' checked={inheritGroupColour} onCheckedChange={setInheritGroupColour} />
</Panel.ListItem>
<Panel.ListItem> <Panel.ListItem>
<Panel.Field <Panel.Field
title='Timer strategy' title='Timer strategy'
@@ -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,
@@ -22,7 +25,7 @@ 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();
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,
}; };
} }
@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react';
import { generateUrl } from '../../../../common/api/session';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import { serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
export default function McpSection() {
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
// generate url
useEffect(() => {
// the page origin is reachable wherever the user is browsing from,
// and follows the deployment (cloud, reverse proxy, port mappings)
// we are reusing the endpoint, so locking config and nav have no effect
generateUrl({ baseUrl: serverURL, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
.then(setMcpEndpointUrl)
.catch(() => {
setMcpEndpointUrl('');
});
}, []);
const mcpClientConfig = mcpEndpointUrl
? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2)
: '';
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>MCP Server</Panel.SubHeader>
<Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph>
<Panel.Divider />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
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.Section>
);
}
@@ -4,6 +4,7 @@ import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import CustomViews from '../manage-panel/CustomViews'; import CustomViews from '../manage-panel/CustomViews';
import GeneralSettings from './GeneralSettings'; import GeneralSettings from './GeneralSettings';
import McpSection from './McpSection';
import ProjectData from './ProjectData'; import ProjectData from './ProjectData';
import ServerPortSettings from './ServerPortSettings'; import ServerPortSettings from './ServerPortSettings';
import ViewSettings from './ViewSettings'; import ViewSettings from './ViewSettings';
@@ -13,6 +14,7 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
const generalRef = useScrollIntoView<HTMLDivElement>('general', location); const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location); const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location); const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
const portRef = useScrollIntoView<HTMLDivElement>('port', location); const portRef = useScrollIntoView<HTMLDivElement>('port', location);
return ( return (
@@ -30,6 +32,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
<div ref={customViewsRef}> <div ref={customViewsRef}>
<CustomViews /> <CustomViews />
</div> </div>
<div ref={mcpRef}>
<McpSection />
</div>
{!isDocker && ( {!isDocker && (
<div ref={portRef}> <div ref={portRef}>
<ServerPortSettings /> <ServerPortSettings />
@@ -1,5 +1,5 @@
import { TranslationObject, langEn } from 'ontime-types'; import { TranslationObject, langEn } from 'ontime-types';
import { useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../common/api/utils';
@@ -10,6 +10,8 @@ import Modal from '../../../../../common/components/modal/Modal';
import { useTranslation } from '../../../../../translation/TranslationProvider'; import { useTranslation } from '../../../../../translation/TranslationProvider';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './CustomTranslationModal.module.scss';
interface CustomTranslationModalProps { interface CustomTranslationModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -17,14 +19,7 @@ interface CustomTranslationModalProps {
export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) { export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) {
const { userTranslation, postUserTranslation } = useTranslation(); const { userTranslation, postUserTranslation } = useTranslation();
const customTranslationFormValues = useMemo(() => toFormValues(userTranslation), [userTranslation]);
const defaultValues = useMemo(() => {
const values: Record<string, string> = {};
Object.keys(langEn).forEach((key) => {
values[toFormKey(key)] = userTranslation[key as keyof TranslationObject] || '';
});
return values;
}, [userTranslation]);
const { const {
handleSubmit, handleSubmit,
@@ -33,13 +28,17 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
formState: { isSubmitting, isDirty, errors, isValid }, formState: { isSubmitting, isDirty, errors, isValid },
setError, setError,
} = useForm({ } = useForm({
defaultValues, defaultValues: customTranslationFormValues,
resetOptions: {
keepDirtyValues: true,
},
mode: 'onChange', mode: 'onChange',
}); });
// keep data up to date
useEffect(() => {
if (isOpen) {
reset(customTranslationFormValues);
}
}, [customTranslationFormValues, isOpen, reset]);
const onSubmit = async (formData: Record<string, string>) => { const onSubmit = async (formData: Record<string, string>) => {
try { try {
const translationData: Record<string, string> = {}; const translationData: Record<string, string> = {};
@@ -54,6 +53,10 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
} }
}; };
const resetToEnglish = () => {
reset(toFormValues(langEn), { keepDefaultValues: true });
};
return ( return (
<Modal <Modal
title='Edit custom translations' title='Edit custom translations'
@@ -84,9 +87,12 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
</Panel.Section> </Panel.Section>
} }
footerElements={ footerElements={
<div> <div className={style.footer}>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>} {errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements align='apart'> <Panel.InlineElements align='apart'>
<Button variant='ghosted' size='large' onClick={resetToEnglish} disabled={isSubmitting}>
Reset to English
</Button>
<Panel.InlineElements> <Panel.InlineElements>
<Button size='large' onClick={onClose}> <Button size='large' onClick={onClose}>
Cancel Cancel
@@ -109,6 +115,14 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
); );
} }
function toFormValues(translation: Partial<TranslationObject>) {
const values: Record<string, string> = {};
Object.keys(langEn).forEach((key) => {
values[toFormKey(key)] = translation[key as keyof TranslationObject] || '';
});
return values;
}
function toFormKey(key: string) { function toFormKey(key: string) {
return key.replace('.', '_'); return key.replace('.', '_');
} }
@@ -19,7 +19,8 @@ const staticOptions = [
{ id: 'settings__general', label: 'General settings' }, { id: 'settings__general', label: 'General settings' },
{ id: 'settings__view', label: 'View settings' }, { id: 'settings__view', label: 'View settings' },
{ id: 'settings__custom-views', label: 'Custom views' }, { id: 'settings__custom-views', label: 'Custom views' },
{ id: 'settings__port', label: 'Server port' }, { id: 'settings__mcp', label: 'MCP Server' },
...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port' }]),
], ],
}, },
{ {
@@ -96,18 +97,12 @@ export function useAppSettingsMenu() {
const options: Readonly<SettingsOption[]> = useMemo( const options: Readonly<SettingsOption[]> = useMemo(
() => () =>
staticOptions.map((option) => ({ staticOptions.map((option) =>
...option,
// if we are in docker don't show the port option
secondary:
'secondary' in option
? isDocker && option.id === 'settings'
? [...option.secondary.filter(({ id }) => id !== 'settings__port')]
: [...option.secondary]
: undefined,
// if there is an update then highlight the about setting // if there is an update then highlight the about setting
highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined, option.id === 'about' && data.hasUpdates
})), ? Object.assign({}, option, { highlight: 'New version available' })
: option,
),
[data], [data],
); );
@@ -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>
);
} }
+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 && (
@@ -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>;
} }
@@ -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,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>
@@ -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]),
}, },
]); ]);
@@ -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' />
))}
</> </>
); );
} }
@@ -1,6 +1,6 @@
import { OntimeView, URLPreset } from 'ontime-types'; import { OntimeView, URLPreset } from 'ontime-types';
import { generateId } from 'ontime-utils'; import { generateId } from 'ontime-utils';
import { useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import { FieldErrors, useForm } from 'react-hook-form'; import { FieldErrors, useForm } from 'react-hook-form';
import { generateUrl } from '../../common/api/session'; import { generateUrl } from '../../common/api/session';
@@ -18,7 +18,7 @@ import { preventEscape } from '../../common/utils/keyEvent';
import { isUrlSafe } from '../../common/utils/regex'; import { isUrlSafe } from '../../common/utils/regex';
import { isOntimeCloud, serverURL } from '../../externals'; import { isOntimeCloud, serverURL } from '../../externals';
import * as Panel from '../app-settings/panel-utils/PanelUtils'; import * as Panel from '../app-settings/panel-utils/PanelUtils';
import CuesheetLinkOptions from './composite/CuesheetLinkOptions'; import CuesheetLinkOptions, { CuesheetPermissionValues } from './composite/CuesheetLinkOptions';
import style from './GenerateLinkForm.module.scss'; import style from './GenerateLinkForm.module.scss';
@@ -53,12 +53,26 @@ type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) { export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) {
const [formState, setFormState] = useState<GenerateLinkState>('pending'); const [formState, setFormState] = useState<GenerateLinkState>('pending');
const [url, setUrl] = useState(serverURL); const [url, setUrl] = useState('');
const cuesheetReadRef = useRef<HTMLInputElement>(null); const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>({
const cuesheetWriteRef = useRef<HTMLInputElement>(null); read: 'full',
write: 'full',
});
const generatedAlias = useRef<string>(`cuesheet-${generateId()}`); const generatedAlias = useRef<string>(`cuesheet-${generateId()}`);
const { addPreset } = useUpdateUrlPreset(); const { addPreset, updatePreset } = useUpdateUrlPreset();
// Tracks the alias we already created this session so re-generating updates rather than duplicates it
const createdAlias = useRef<string | null>(null);
/**
* Permissions live outside react-hook-form, so we reset a successful state manually
* whenever they change - this re-arms the "Create share link" button as the previous
* link no longer reflects the selected permissions.
*/
const handlePermissionsChange = useCallback((permissions: CuesheetPermissionValues) => {
setCuesheetPermissions(permissions);
setFormState((current) => (current === 'success' ? 'pending' : current));
}, []);
const { const {
handleSubmit, handleSubmit,
@@ -90,16 +104,20 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
if (options.read === '-') { if (options.read === '-') {
throw new Error('Cannot create a share with no read permissions'); throw new Error('Cannot create a share with no read permissions');
} }
const presets = await addPreset({ const payload = {
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
enabled: true, enabled: true,
alias, alias,
search: '', search: '',
displayInNav: false,
options: { options: {
read: options.read, read: options.read,
write: options.write, write: options.write,
}, },
}); } as const;
// Re-generating with the same name updates the existing preset instead of failing on a duplicate alias
const presets = createdAlias.current === alias ? await updatePreset(alias, payload) : await addPreset(payload);
createdAlias.current = alias;
return presets.find((preset) => preset.alias === alias); return presets.find((preset) => preset.alias === alias);
}; };
@@ -108,8 +126,8 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
setFormState('loading'); setFormState('loading');
if (options.path === OntimeView.Cuesheet) { if (options.path === OntimeView.Cuesheet) {
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, { const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
read: cuesheetReadRef.current?.value ?? 'full', read: cuesheetPermissions.read,
write: cuesheetWriteRef.current?.value ?? 'full', write: cuesheetPermissions.write,
}); });
if (!urlPreset) { if (!urlPreset) {
@@ -157,6 +175,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
} }
}; };
const noReadAccess = watch('path') === OntimeView.Cuesheet && cuesheetPermissions.read === '-';
const canSubmit = isDirty || formState !== 'success'; const canSubmit = isDirty || formState !== 'success';
return ( return (
@@ -220,7 +239,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
})} })}
/> />
</Panel.ListItem> </Panel.ListItem>
<CuesheetLinkOptions readRef={cuesheetReadRef} writeRef={cuesheetWriteRef} /> <CuesheetLinkOptions onChange={handlePermissionsChange} />
</> </>
)} )}
@@ -268,18 +287,29 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
</Panel.ListGroup> </Panel.ListGroup>
<Panel.Error>{errors.root?.message}</Panel.Error> <Panel.Error>{errors.root?.message}</Panel.Error>
<Panel.InlineElements align='end' className={style.end}> <Panel.InlineElements align='end' className={style.end}>
<Button type='submit' variant={canSubmit ? 'primary' : 'subtle'} loading={formState === 'loading'}> <Button
type='submit'
variant={canSubmit ? 'primary' : 'subtle'}
loading={formState === 'loading'}
disabled={noReadAccess}
>
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'} {canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
</Button> </Button>
</Panel.InlineElements> </Panel.InlineElements>
</div> </div>
<Panel.Section className={style.column}> <Panel.Section className={style.column}>
<Panel.Description>Share this link</Panel.Description> <Panel.Description>Share this link</Panel.Description>
<QRCode size={172} value={url} /> {url ? (
<div className={style.copiableLink} data-testid='copy-link'> <>
{url} <QRCode size={172} value={url} />
</div> <div className={style.copiableLink} data-testid='copy-link'>
<CopyTag copyValue={url}>Copy link</CopyTag> {url}
</div>
<CopyTag copyValue={url}>Copy link</CopyTag>
</>
) : (
<Panel.Description>Your link will appear here once you create it.</Panel.Description>
)}
</Panel.Section> </Panel.Section>
</div> </div>
</form> </form>
@@ -3,6 +3,7 @@ import { useMemo } from 'react';
import useInfo from '../../common/hooks-query/useInfo'; import useInfo from '../../common/hooks-query/useInfo';
import useUrlPresets from '../../common/hooks-query/useUrlPresets'; import useUrlPresets from '../../common/hooks-query/useUrlPresets';
import { hostToBaseUrl } from '../../common/utils/linkUtils';
import GenerateLinkForm from './GenerateLinkForm'; import GenerateLinkForm from './GenerateLinkForm';
interface GenerateLinkFormExportProps { interface GenerateLinkFormExportProps {
@@ -21,7 +22,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
*/ */
const hostOptions = useMemo(() => { const hostOptions = useMemo(() => {
return infoData.networkInterfaces.map((nif) => ({ return infoData.networkInterfaces.map((nif) => ({
value: `http://${nif.address}:${infoData.serverPort}`, value: hostToBaseUrl(nif.address, infoData.serverPort),
label: `${nif.name} - ${nif.address}`, label: `${nif.name} - ${nif.address}`,
})); }));
}, [infoData.networkInterfaces, infoData.serverPort]); }, [infoData.networkInterfaces, infoData.serverPort]);
@@ -1,4 +1,4 @@
import { Fragment, RefObject, useMemo, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import RadioGroup from '../../../common/components/radio-group/RadioGroup'; import RadioGroup from '../../../common/components/radio-group/RadioGroup';
import Switch from '../../../common/components/switch/Switch'; import Switch from '../../../common/components/switch/Switch';
@@ -10,118 +10,141 @@ import style from './CuesheetLinkOptions.module.scss';
type AccessMode = 'full' | 'custom'; type AccessMode = 'full' | 'custom';
interface CuesheetLinkOptionsProps { export interface CuesheetPermissionValues {
readRef?: RefObject<HTMLInputElement | null>; read: string;
writeRef?: RefObject<HTMLInputElement | null>; write: string;
} }
export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkOptionsProps) { interface CuesheetLinkOptionsProps {
/** Existing read permission to seed the form with ('full' | '-' | comma separated keys) */
initialRead?: string;
/** Existing write permission to seed the form with ('full' | '-' | comma separated keys) */
initialWrite?: string;
/** Notifies the parent whenever the resolved read/write permissions change */
onChange: (permissions: CuesheetPermissionValues) => void;
}
/** A null result means "full or unset" - there is no explicit per-column selection to seed */
function parseKeys(permission: string | undefined): Set<string> | null {
if (permission == null || permission === 'full') {
return null;
}
if (permission === '-') {
return new Set();
}
return new Set(permission.split(','));
}
function modeFromPermission(permission: string | undefined): AccessMode {
return permission == null || permission === 'full' ? 'full' : 'custom';
}
export default function CuesheetLinkOptions({ initialRead, initialWrite, onChange }: CuesheetLinkOptionsProps) {
const { data } = useCustomFields(); const { data } = useCustomFields();
const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]); const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
const allColumns = useMemo(() => [...cuesheetDefaultColumns, ...customFieldColumns], [customFieldColumns]);
const [readPermissions, setReadPermissions] = useState<AccessMode>('full'); // Parsed seed values - stable for the lifetime of a given preset
const [writePermissions, setWritePermissions] = useState<AccessMode>('full'); const initialReadKeys = useMemo(() => parseKeys(initialRead), [initialRead]);
const initialWriteKeys = useMemo(() => parseKeys(initialWrite), [initialWrite]);
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>(() => { const [readPermissions, setReadPermissions] = useState<AccessMode>(() => modeFromPermission(initialRead));
const initialState: Record<string, boolean> = {}; const [writePermissions, setWritePermissions] = useState<AccessMode>(() => modeFromPermission(initialWrite));
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
initialState[column.value] = true; // Default for a column we have not seen yet: honour the seed in custom mode, otherwise grant access
const defaultRead = useCallback(
(key: string) => (initialReadKeys ? initialReadKeys.has(key) : true),
[initialReadKeys],
);
const defaultWrite = useCallback(
(key: string) => (initialWriteKeys ? initialWriteKeys.has(key) : true),
[initialWriteKeys],
);
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>({});
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>({});
// Custom fields load asynchronously, so reconcile the switch maps whenever the column list grows.
// Newly seen columns are seeded from the initial values (or default to on for a fresh link).
useEffect(() => {
setReadSwitches((prev) => {
const next = { ...prev };
for (const column of allColumns) {
if (!(column.value in next)) next[column.value] = defaultRead(column.value);
}
return next;
}); });
return initialState; setWriteSwitches((prev) => {
}); const next = { ...prev };
for (const column of allColumns) {
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>(() => { if (!(column.value in next)) next[column.value] = defaultWrite(column.value);
const initialState: Record<string, boolean> = {}; }
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => { return next;
initialState[column.value] = true;
}); });
return initialState; }, [allColumns, defaultRead, defaultWrite]);
});
const isReadOn = (key: string) => readSwitches[key] ?? defaultRead(key);
const isWriteOn = (key: string) => writeSwitches[key] ?? defaultWrite(key);
const handleReadModeChange = (value: AccessMode) => { const handleReadModeChange = (value: AccessMode) => {
setReadPermissions(value); setReadPermissions(value);
setReadSwitches((prevReadSwitches) => {
const updatedReadSwitches = { ...prevReadSwitches };
Object.keys(updatedReadSwitches).forEach((key) => {
updatedReadSwitches[key] = true;
});
return updatedReadSwitches;
});
}; };
const handleWriteModeChange = (value: AccessMode) => { const handleWriteModeChange = (value: AccessMode) => {
setWritePermissions(value);
// Full write implies full read
if (value === 'full') { if (value === 'full') {
setReadPermissions('full'); setReadPermissions('full');
} }
setWritePermissions(value);
setReadSwitches((prevReadSwitches) => {
const updatedReadSwitches = { ...prevReadSwitches };
setWriteSwitches((prevWriteSwitches) => {
const updatedWriteSwitches = { ...prevWriteSwitches };
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
updatedReadSwitches[column.value] = true;
updatedWriteSwitches[column.value] = true;
});
return updatedWriteSwitches;
});
return updatedReadSwitches;
});
}; };
const handleSwitchChange = (key: string, type: 'read' | 'write', value: boolean) => { const handleReadSwitch = (key: string, value: boolean) => {
if (type === 'read') { setReadSwitches((prev) => ({ ...prev, [key]: value }));
setReadSwitches((prevReadSwitches) => { // A column the recipient cannot read cannot be written either
const updatedReadSwitches = { ...prevReadSwitches, [key]: value }; if (!value) {
return updatedReadSwitches; setWriteSwitches((prev) => ({ ...prev, [key]: false }));
});
} else {
setWriteSwitches((prevWriteSwitches) => {
const updatedWriteSwitches = { ...prevWriteSwitches, [key]: value };
return updatedWriteSwitches;
});
} }
}; };
const getReadPermissions = () => { const handleWriteSwitch = (key: string, value: boolean) => {
setWriteSwitches((prev) => ({ ...prev, [key]: value }));
// Granting write access requires read access
if (value) {
setReadSwitches((prev) => ({ ...prev, [key]: true }));
}
};
const resolvedRead = useMemo(() => {
if (readPermissions === 'full' || writePermissions === 'full') { if (readPermissions === 'full' || writePermissions === 'full') {
return 'full'; return 'full';
} }
const keys = allColumns.filter((column) => isReadOn(column.value)).map((column) => column.value);
return keys.length ? keys.join(',') : '-';
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readPermissions, writePermissions, readSwitches, allColumns]);
return Object.entries(readSwitches) const resolvedWrite = useMemo(() => {
.filter(([_, value]) => value)
.map(([key]) => key)
.join(',');
};
const getWritePermissions = () => {
if (writePermissions === 'full') { if (writePermissions === 'full') {
return 'full'; return 'full';
} }
const keys = allColumns.filter((column) => isWriteOn(column.value)).map((column) => column.value);
return keys.length ? keys.join(',') : '-';
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writePermissions, writeSwitches, allColumns]);
return Object.entries(writeSwitches) // Notify the parent of the resolved permissions. onChange is expected to be stable.
.filter(([_, value]) => value) useEffect(() => {
.map(([key]) => key) onChange({ read: resolvedRead, write: resolvedWrite });
.join(','); // eslint-disable-next-line react-hooks/exhaustive-deps
}; }, [resolvedRead, resolvedWrite]);
const noReadAccess = resolvedRead === '-';
return ( return (
<Panel.Indent> <Panel.Indent>
<input name='read' hidden readOnly ref={readRef} value={getReadPermissions() || '-'} />
<input name='write' hidden readOnly ref={writeRef} value={getWritePermissions() || '-'} />
<div> <div>
<Panel.Field title='Access mode' description='Which parts of the data will the link give access to' /> <Panel.Field title='Access mode' description='Which parts of the data the link gives access to' />
<div> <div>
<RadioGroup
value={writePermissions}
onValueChange={handleWriteModeChange}
orientation='horizontal'
items={[
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
{ value: 'custom', label: 'Custom write' },
]}
/>
<RadioGroup <RadioGroup
value={readPermissions} value={readPermissions}
onValueChange={handleReadModeChange} onValueChange={handleReadModeChange}
@@ -132,8 +155,18 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
{ value: 'custom', label: 'Custom read' }, { value: 'custom', label: 'Custom read' },
]} ]}
/> />
<RadioGroup
value={writePermissions}
onValueChange={handleWriteModeChange}
orientation='horizontal'
items={[
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
{ value: 'custom', label: 'Custom write' },
]}
/>
</div> </div>
</div> </div>
{noReadAccess && <Panel.Error>Links must contain at least one readable column.</Panel.Error>}
<div className={style.twoCols}> <div className={style.twoCols}>
<div className={style.grid}> <div className={style.grid}>
<Panel.Description>Ontime columns</Panel.Description> <Panel.Description>Ontime columns</Panel.Description>
@@ -143,14 +176,14 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
<Fragment key={column.value}> <Fragment key={column.value}>
<div>{column.label}</div> <div>{column.label}</div>
<Switch <Switch
checked={Boolean(readSwitches[column.value])} checked={isReadOn(column.value)}
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)} onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
disabled={readPermissions === 'full' || writePermissions === 'full'} disabled={readPermissions === 'full' || writePermissions === 'full'}
data-testid={`read-${column.value}`} data-testid={`read-${column.value}`}
/> />
<Switch <Switch
checked={Boolean(writeSwitches[column.value])} checked={isWriteOn(column.value)}
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)} onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
disabled={writePermissions === 'full'} disabled={writePermissions === 'full'}
data-testid={`write-${column.value}`} data-testid={`write-${column.value}`}
/> />
@@ -164,16 +197,16 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
<Panel.Description>Write</Panel.Description> <Panel.Description>Write</Panel.Description>
{customFieldColumns.map((column) => ( {customFieldColumns.map((column) => (
<Fragment key={column.value}> <Fragment key={column.value}>
{column.label} <div>{column.label}</div>
<Switch <Switch
checked={Boolean(readSwitches[column.value])} checked={isReadOn(column.value)}
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)} onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
disabled={readPermissions === 'full' || writePermissions === 'full'} disabled={readPermissions === 'full' || writePermissions === 'full'}
data-testid={`read-${column.value}`} data-testid={`read-${column.value}`}
/> />
<Switch <Switch
checked={Boolean(writeSwitches[column.value])} checked={isWriteOn(column.value)}
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)} onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
disabled={writePermissions === 'full'} disabled={writePermissions === 'full'}
data-testid={`write-${column.value}`} data-testid={`write-${column.value}`}
/> />
+1 -1
View File
@@ -82,5 +82,5 @@ export const initializeSentry = () => {
}, },
}); });
return Sentry.withSentryReactRouterV6Routing(Routes); return Sentry.withSentryReactRouterV7Routing(Routes);
}; };
@@ -18,6 +18,7 @@ export const langDe: TranslationObject = {
'common.no_data': 'Keine Daten', 'common.no_data': 'Keine Daten',
'countdown.ended': 'Veranstaltung endete um', 'countdown.ended': 'Veranstaltung endete um',
'countdown.running': 'Veranstaltung läuft', 'countdown.running': 'Veranstaltung läuft',
'countdown.group_running': 'Veranstaltung in Gruppe läuft',
'countdown.loaded': 'Veranstaltung geladen', 'countdown.loaded': 'Veranstaltung geladen',
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen', 'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
'countdown.to_start': 'Zeit bis zum Start', 'countdown.to_start': 'Zeit bis zum Start',
@@ -28,6 +29,7 @@ export const langDe: TranslationObject = {
'timeline.done': 'Beendet', 'timeline.done': 'Beendet',
'timeline.due': 'fällig', 'timeline.due': 'fällig',
'timeline.followedby': 'Gefolgt von', 'timeline.followedby': 'Gefolgt von',
'timeline.standby': 'Bereitschaft',
'project.title': 'Titel', 'project.title': 'Titel',
'project.description': 'Beschreibung', 'project.description': 'Beschreibung',
'project.info': 'Projektinfo', 'project.info': 'Projektinfo',
@@ -18,6 +18,7 @@ export const langEs: TranslationObject = {
'common.no_data': 'Sin datos', 'common.no_data': 'Sin datos',
'countdown.ended': 'Evento finalizado a las', 'countdown.ended': 'Evento finalizado a las',
'countdown.running': 'Evento en curso', 'countdown.running': 'Evento en curso',
'countdown.group_running': 'Evento en grupo en curso',
'countdown.loaded': 'Evento está cargado', //TODO: check translation 'countdown.loaded': 'Evento está cargado', //TODO: check translation
'countdown.select_event': 'Seleccionar un evento para seguir', 'countdown.select_event': 'Seleccionar un evento para seguir',
'countdown.to_start': 'Tiempo para comenzar', 'countdown.to_start': 'Tiempo para comenzar',
@@ -28,6 +29,7 @@ export const langEs: TranslationObject = {
'timeline.done': 'Terminado', 'timeline.done': 'Terminado',
'timeline.due': 'pendiente', 'timeline.due': 'pendiente',
'timeline.followedby': 'Seguido por', 'timeline.followedby': 'Seguido por',
'timeline.standby': 'En espera',
'project.title': 'Título', 'project.title': 'Título',
'project.description': 'Descripción', 'project.description': 'Descripción',
'project.info': 'Información del proyecto', 'project.info': 'Información del proyecto',
@@ -18,6 +18,7 @@ export const langFr: TranslationObject = {
'common.no_data': 'Aucune donnée', 'common.no_data': 'Aucune donnée',
'countdown.ended': 'Évènement terminé à', 'countdown.ended': 'Évènement terminé à',
'countdown.running': 'Évènement en cours', 'countdown.running': 'Évènement en cours',
'countdown.group_running': 'Évènement du groupe en cours',
'countdown.loaded': 'Évènement chargé', 'countdown.loaded': 'Évènement chargé',
'countdown.select_event': 'Sélectionnez un évènement à suivre', 'countdown.select_event': 'Sélectionnez un évènement à suivre',
'countdown.to_start': 'Évènement commence dans', 'countdown.to_start': 'Évènement commence dans',
@@ -28,6 +29,7 @@ export const langFr: TranslationObject = {
'timeline.done': 'Terminé', 'timeline.done': 'Terminé',
'timeline.due': 'dû', 'timeline.due': 'dû',
'timeline.followedby': 'Suivi de', 'timeline.followedby': 'Suivi de',
'timeline.standby': 'En attente',
'project.title': 'Titre', 'project.title': 'Titre',
'project.description': 'Description', 'project.description': 'Description',
'project.info': 'Informations du projet', 'project.info': 'Informations du projet',
@@ -18,6 +18,7 @@ export const langIt: TranslationObject = {
'common.no_data': 'Nessun dato disponibile', 'common.no_data': 'Nessun dato disponibile',
'countdown.ended': 'Evento finito alle', 'countdown.ended': 'Evento finito alle',
'countdown.running': 'Evento in corso', 'countdown.running': 'Evento in corso',
'countdown.group_running': 'Evento nel gruppo in corso',
'countdown.loaded': 'Evento caricato', //TODO: check translation 'countdown.loaded': 'Evento caricato', //TODO: check translation
'countdown.select_event': 'Seleziona un evento da seguire', 'countdown.select_event': 'Seleziona un evento da seguire',
'countdown.to_start': 'Tempo alla partenza', 'countdown.to_start': 'Tempo alla partenza',
@@ -28,6 +29,7 @@ export const langIt: TranslationObject = {
'timeline.done': 'Terminato', 'timeline.done': 'Terminato',
'timeline.due': 'previsto', 'timeline.due': 'previsto',
'timeline.followedby': 'Seguito da', 'timeline.followedby': 'Seguito da',
'timeline.standby': 'In attesa',
'project.title': 'Titolo', 'project.title': 'Titolo',
'project.description': 'Descrizione', 'project.description': 'Descrizione',
'project.info': 'Informazioni sul progetto', 'project.info': 'Informazioni sul progetto',
@@ -18,6 +18,7 @@ export const langPt: TranslationObject = {
'common.no_data': 'Sem dados', 'common.no_data': 'Sem dados',
'countdown.ended': 'Evento encerrado às', 'countdown.ended': 'Evento encerrado às',
'countdown.running': 'Evento em andamento', 'countdown.running': 'Evento em andamento',
'countdown.group_running': 'Evento em grupo em andamento',
'countdown.loaded': 'Evento carregado', //TODO: check translation 'countdown.loaded': 'Evento carregado', //TODO: check translation
'countdown.select_event': 'Selecione um evento para acompanhar', 'countdown.select_event': 'Selecione um evento para acompanhar',
'countdown.to_start': 'Tempo para iniciar', 'countdown.to_start': 'Tempo para iniciar',
@@ -28,6 +29,7 @@ export const langPt: TranslationObject = {
'timeline.done': 'Concluído', 'timeline.done': 'Concluído',
'timeline.due': 'Pendente', 'timeline.due': 'Pendente',
'timeline.followedby': 'Seguido por', 'timeline.followedby': 'Seguido por',
'timeline.standby': 'Em espera',
'project.title': 'Título', 'project.title': 'Título',
'project.description': 'Descrição', 'project.description': 'Descrição',
'project.info': 'Informações do projeto', 'project.info': 'Informações do projeto',

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