Compare commits

...

28 Commits

Author SHA1 Message Date
Claude 972a246bb6 feat(server): introduce Zod request validation, starting with MCP tools
Adds Zod as the validation library for apps/server (in place of
express-validator), scoped here to a low-risk slice rather than a
full migration:

- MCP tool-call arguments (apps/server/src/api-mcp) are now validated
  end to end. Every tool's inputSchema is generated from its Zod
  schema via z.toJSONSchema() instead of hand-maintained JSON Schema
  literals, and every handler now parses its arguments before use —
  previously every handler did an unchecked `args as SomeType` cast
  with no runtime validation at all.
- A new validate.ts middleware (validateBody/validateParams) replaces
  the express-validator chokepoint for two reference routers, session
  and url-presets, demonstrating the pattern the remaining ~11 routers
  will follow in later PRs.
- An oxlint no-restricted-imports guardrail keeps zod importable only
  from apps/server, so it can never leak into the apps/client bundle.

The bulk of the REST router migration (automation, rundown, and the
rest of api-data) is intentionally left for follow-up PRs to keep this
change reviewable.
2026-07-18 13:34:07 +00: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
Alex Christoffer Rasmussen 5f33c993f6 bump: v4.9.0 (#2095) 2026-06-07 13:53:25 +02:00
Carlos Valente 0e6d6cd416 refactor: allow secondary rundowns (#2086)
* refactor: allow secondary rundowns

* ui: move dropdown

* feat: follow loaded

* ui: background edit warning

ui: fix disable radio button

* feat(ui): add loaded sufix in the rundown list

* feat(ui): add direct link to background edit from rundown manager

* fix: better fallback

* fix: default to is isCurrentRundown for nav bar colour

* chore: rename navigate to cuesheet

---------

Co-authored-by: alex-arc <ac@omnivox.dk>
2026-06-07 13:33:38 +02:00
Carlos Valente 199dea5df6 fix(cuesheet): prevent timer cells from submitting on tab-out or escape (#2083) 2026-06-07 12:59:18 +02:00
Alex Christoffer Rasmussen 5a3ea9f7d0 fix: playwright ci test (#2094) 2026-05-28 09:50:20 +02:00
155 changed files with 6871 additions and 1752 deletions
+10 -2
View File
@@ -15,6 +15,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
@@ -54,6 +58,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
@@ -81,11 +89,11 @@ jobs:
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: | restore-keys: |
${{ runner.os }}-playwright- ${{ runner.os }}-playwright-
- run: npx playwright install --with-deps - run: pnpm playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: npx playwright install --with-deps run: pnpm playwright install --with-deps
- name: Run Playwright tests - name: Run Playwright tests
run: pnpm e2e run: pnpm e2e
+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
@@ -23,7 +23,7 @@
"no-restricted-imports": [ "no-restricted-imports": [
"error", "error",
{ {
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"] "patterns": ["ontime-types/src/*", "ontime-utils/src/*", "zod"]
} }
] ]
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.8.0", "version": "4.10.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.8.0", "version": "4.10.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",
+22 -11
View File
@@ -34,19 +34,17 @@ export async function fetchCurrentRundown(options?: RequestOptions): Promise<Run
throw new Error('Invalid rundown payload'); throw new Error('Invalid rundown payload');
} }
return res.data; return res.data;
}
function isValidRundown(x: any): x is Rundown { /**
return ( * HTTP request to fetch all entries in the given rundown
x && */
typeof x === 'object' && export async function fetchRundown(rundownId: RundownId, options?: RequestOptions): Promise<Rundown> {
typeof x.id === 'string' && const res = await axios.get(`${rundownPath}/${rundownId}`, { signal: options?.signal });
Array.isArray(x.order) && if (!isValidRundown(res.data)) {
Array.isArray(x.flatOrder) && throw new Error('Invalid rundown payload');
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
} }
return res.data;
} }
/** /**
@@ -197,3 +195,16 @@ export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosRespo
} }
// #endregion operations on rundown entries ======================= // #endregion operations on rundown entries =======================
function isValidRundown(x: any): x is Rundown {
return (
x &&
typeof x === 'object' &&
typeof x.id === 'string' &&
Array.isArray(x.order) &&
Array.isArray(x.flatOrder) &&
x.entries &&
typeof x.entries === 'object' &&
typeof x.revision === 'number'
);
}
@@ -3,8 +3,8 @@ import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo,
interface UseReactiveTextInputReturn { interface UseReactiveTextInputReturn {
value: string; value: string;
onChange: (event: ChangeEvent) => void; onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onBlur: (event: ChangeEvent) => void; onBlur: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void; onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
} }
@@ -15,7 +15,9 @@ export default function useReactiveTextInput(
options?: { options?: {
submitOnEnter?: boolean; submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean; submitOnCtrlEnter?: boolean;
submitOnTab?: boolean;
onCancelUpdate?: () => void; onCancelUpdate?: () => void;
onTabCancel?: () => void;
allowSubmitSameValue?: boolean; allowSubmitSameValue?: boolean;
allowKeyboardNavigation?: boolean; allowKeyboardNavigation?: boolean;
}, },
@@ -23,6 +25,10 @@ export default function useReactiveTextInput(
const [text, setText] = useState<string>(initialText); const [text, setText] = useState<string>(initialText);
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur // track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
const isKeyboardSubmitting = useRef(false); const isKeyboardSubmitting = useRef(false);
// track escape to prevent the subsequent blur from submitting
const isEscaping = useRef(false);
// track tab-out to prevent blur from submitting (used by timer cells)
const isTabbing = useRef(false);
useEffect(() => { useEffect(() => {
if (typeof initialText === 'undefined') { if (typeof initialText === 'undefined') {
@@ -78,6 +84,7 @@ export default function useReactiveTextInput(
* @param {string} valueToSubmit * @param {string} valueToSubmit
*/ */
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
isEscaping.current = true;
// No need to update if it hasn't changed // No need to update if it hasn't changed
setText(initialText); setText(initialText);
// force the text to be the initial value // force the text to be the initial value
@@ -141,16 +148,30 @@ export default function useReactiveTextInput(
event.stopPropagation(); event.stopPropagation();
} }
// for cells that opt out of tab-submit, track tab-out without preventing navigation
if (event.key === 'Tab' && options?.submitOnTab === false) {
isTabbing.current = true;
}
hotKeyHandler(event); hotKeyHandler(event);
}; };
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]); }, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]);
return { return {
value: text, value: text,
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value), onChange: (event) => handleChange(event.target.value),
onBlur: (event: ChangeEvent) => { onBlur: (event) => {
if (isTabbing.current) {
isTabbing.current = false;
(options?.onTabCancel ?? options?.onCancelUpdate)?.();
return;
}
if (isEscaping.current) {
isEscaping.current = false;
return;
}
if (!isKeyboardSubmitting.current) { if (!isKeyboardSubmitting.current) {
handleSubmit((event.target as HTMLInputElement).value); handleSubmit(event.target.value);
} }
}, },
onKeyDown: keyHandler, onKeyDown: keyHandler,
@@ -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,16 @@
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 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 +23,100 @@ 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' />
<IconButton
variant='ghosted-white'
size='small'
className={style.clear}
aria-label='Clear saved view settings'
title='Clear saved view settings'
onClick={clearViewSettings}
>
<IoCloseOutline />
</IconButton>
</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
@@ -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>
@@ -12,6 +12,7 @@ import {
renameRundown, renameRundown,
} from '../api/rundown'; } from '../api/rundown';
//TODO: make suspends so we don't have to deal with no value all over
/** /**
* Project rundowns * Project rundowns
*/ */
@@ -4,7 +4,7 @@ import { useEffect, useMemo } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants'; import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown } from '../api/rundown'; import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket'; import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns'; import { useProjectRundowns } from './useProjectRundowns';
@@ -20,7 +20,11 @@ const cachedRundownPlaceholder: Rundown = {
}; };
/** /**
* Normalised rundown data * Normalised rundown data for the currently loaded rundown.
*
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
* independent of the project rundown list. Once the loaded id is known, the
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
*/ */
export default function useRundown() { export default function useRundown() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -34,7 +38,7 @@ export default function useRundown() {
refetchInterval: queryRefetchIntervalSlow, refetchInterval: queryRefetchIntervalSlow,
}); });
// Seed the ID-based cache when fetching via the 'current' alias (bootstrap) // Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => { useEffect(() => {
if (!data || loadedRundownId) return; if (!data || loadedRundownId) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data); queryClient.setQueryData(getRundownQueryKey(data.id), data);
@@ -115,3 +119,21 @@ export function useRundownAuxData() {
}, [data]); }, [data]);
return { data: filteredData, status }; return { data: filteredData, status };
} }
/**
* Provides access to a specific rundown by ID.
* When rundownId is null/undefined the query is disabled and returns the placeholder.
*/
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -0,0 +1,54 @@
import { EntryId, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { useRundownById } from './useRundown';
export type RundownSource = {
rundownId: string | null;
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
selectedEventId: EntryId | null;
};
/**
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
*/
export function useScopedRundown(rundownId: string | null): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
return useRundownSource(rundownId, projectRundowns.loaded || null);
}
/**
* Loaded-rundown source for views that must follow the active runtime rundown.
*/
export function useLoadedRundownSource(): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
const loadedRundownId = projectRundowns.loaded || null;
return useRundownSource(loadedRundownId, loadedRundownId);
}
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundownId,
rundown,
flatRundown,
status,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
);
}
+26 -8
View File
@@ -16,6 +16,7 @@ import {
TimeStrategy, TimeStrategy,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
isOntimeMilestone,
} from 'ontime-types'; } from 'ontime-types';
import { import {
MILLIS_PER_SECOND, MILLIS_PER_SECOND,
@@ -63,9 +64,16 @@ export type EventOptions = Partial<{
}>; }>;
/** /**
* Gather utilities for actions on entries * Gather utilities for actions on entries in the loaded rundown.
*/ */
export const useEntryActions = () => { export const useEntryActions = () => useEntryActionsForRundown(undefined);
/**
* Gather utilities for actions on entries in an explicitly selected rundown.
*/
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
linkPrevious, linkPrevious,
@@ -75,15 +83,16 @@ export const useEntryActions = () => {
defaultDangerTime, defaultDangerTime,
defaultTimerType, defaultTimerType,
defaultEndAction, defaultEndAction,
inheritGroupColour,
} = useEditorSettings(); } = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => { const resolveCurrentRundownQueryKey = useCallback(() => {
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded; if (scopedRundownId !== undefined) {
if (loadedRundownId) { return getRundownQueryKey(scopedRundownId);
return getRundownQueryKey(loadedRundownId);
} }
return CURRENT_RUNDOWN_QUERY_KEY; const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
}, [queryClient]); return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient, scopedRundownId]);
/** /**
* Returns the currently loaded rundown * Returns the currently loaded rundown
@@ -232,6 +241,14 @@ export const useEntryActions = () => {
} }
} }
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) {
@@ -247,6 +264,7 @@ export const useEntryActions = () => {
defaultTimerType, defaultTimerType,
defaultEndAction, defaultEndAction,
defaultTimeStrategy, defaultTimeStrategy,
inheritGroupColour,
addEntryMutation, addEntryMutation,
], ],
); );
@@ -1004,7 +1022,7 @@ export const useEntryActions = () => {
updateTimer, updateTimer,
], ],
); );
}; }
/** /**
* Utility to optimistically delete entries from client cache * Utility to optimistically delete entries from client cache
@@ -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');
});
});
@@ -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}`;
}
+7 -2
View File
@@ -14,8 +14,8 @@ import {
APP_SETTINGS, APP_SETTINGS,
CLIENT_LIST, CLIENT_LIST,
CSS_OVERRIDE, CSS_OVERRIDE,
CUSTOM_FIELDS,
CURRENT_RUNDOWN_QUERY_KEY, CURRENT_RUNDOWN_QUERY_KEY,
CUSTOM_FIELDS,
PROJECT_DATA, PROJECT_DATA,
REPORT, REPORT,
RUNDOWN, RUNDOWN,
@@ -213,6 +213,9 @@ export const connectSocket = () => {
case RefetchKey.Settings: case RefetchKey.Settings:
ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS }); ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS });
break; break;
case RefetchKey.ProjectRundowns:
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
break;
default: { default: {
target satisfies never; target satisfies never;
break; break;
@@ -242,7 +245,9 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
// skip if we dont recognise the ID the revision is lower // skip if we dont recognise the ID the revision is lower
const queryKey = getRundownQueryKey(rundownId); const queryKey = getRundownQueryKey(rundownId);
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey); const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
if (revision !== null && revision === cachedRundown?.revision) {
if (revision === cachedRundown?.revision) {
// we already have the latest change
return; return;
} }
@@ -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 {
@@ -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>
@@ -9,6 +9,7 @@ import {
IoPencilOutline, IoPencilOutline,
IoTrash, IoTrash,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { TbGhost3 } from 'react-icons/tb';
import { downloadAsExcel } from '../../../../common/api/excel'; import { downloadAsExcel } from '../../../../common/api/excel';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
@@ -19,6 +20,7 @@ import { DropdownMenu } from '../../../../common/components/dropdown-menu/Dropdo
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm'; import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm'; import { ManageRundownForm } from './ManageRundownForm';
@@ -97,6 +99,8 @@ export default function ManageRundowns() {
await downloadAsExcel(rundownId, title); await downloadAsExcel(rundownId, title);
}; };
const navigateToCuesheet = useDirectLinkToBackgroundEdit();
return ( return (
<> <>
<Panel.Section> <Panel.Section>
@@ -153,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',
@@ -180,6 +184,12 @@ export default function ManageRundowns() {
label: 'Duplicate', label: 'Duplicate',
onClick: () => submitRundownDuplicate(id), onClick: () => submitRundownDuplicate(id),
}, },
{
type: 'item',
icon: TbGhost3,
label: 'Edit in cuesheet',
onClick: () => navigateToCuesheet(id),
},
{ type: 'divider' }, { type: 'divider' },
{ {
type: 'destructive', type: 'destructive',
@@ -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'
@@ -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],
); );
@@ -6,6 +6,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../common/hooks/useEntryAction'; import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
@@ -104,6 +105,8 @@ interface RundownRootProps {
} }
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) { function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return ( return (
<div className={style.rundownRoot}> <div className={style.rundownRoot}>
{isSmallDevice ? ( {isSmallDevice ? (
@@ -112,7 +115,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} /> <RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)} )}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />} {viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal />} {viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
<RenumberCuesDialog /> <RenumberCuesDialog />
</div> </div>
); );
@@ -1,7 +1,6 @@
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { OntimeEntry, Rundown, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import useRundown from '../../../common/hooks-query/useRundown';
import EventEditor from './EventEditor'; import EventEditor from './EventEditor';
import GroupEditor from './GroupEditor'; import GroupEditor from './GroupEditor';
import MilestoneEditor from './MilestoneEditor'; import MilestoneEditor from './MilestoneEditor';
@@ -10,19 +9,18 @@ import style from './EntryEditor.module.scss';
interface CuesheetEntryEditorProps { interface CuesheetEntryEditorProps {
entryId: string; entryId: string;
rundown: Rundown;
} }
export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) { export default function CuesheetEntryEditor({ entryId, rundown }: CuesheetEntryEditorProps) {
const { data } = useRundown();
const entry = useMemo<OntimeEntry | null>(() => { const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) { if (rundown.order.length === 0) {
return null; return null;
} }
const event = data.entries[entryId]; const event = rundown.entries[entryId];
return event ?? null; return event ?? null;
}, [entryId, data.order, data.entries]); }, [entryId, rundown.entries, rundown.order.length]);
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
return ( return (
@@ -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';
@@ -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>
@@ -1,39 +0,0 @@
import { Toolbar } from '@base-ui/react/toolbar';
import type { Column } from '@tanstack/react-table';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import {
ColumnSettings,
ViewSettings,
} from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings';
import { usePersistedRundownOptions } from '../rundown.options';
import style from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss';
interface EditorTableSettingsProps {
columns: Column<ExtendedEntry, unknown>[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
}
export default function EditorTableSettings({
columns,
handleResetResizing,
handleResetReordering,
handleClearToggles,
}: EditorTableSettingsProps) {
const options = usePersistedRundownOptions();
return (
<Toolbar.Root className={style.tableSettings}>
<ViewSettings optionsStore={options} />
<ColumnSettings
columns={columns}
handleResetResizing={handleResetResizing}
handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles}
/>
</Toolbar.Root>
);
}
@@ -1,7 +1,10 @@
import { memo, useEffect, useMemo } from 'react'; import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage'; import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable'; import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions'; import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -13,6 +16,8 @@ function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields(); const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode(); const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions // Editor always has full permissions
useEffect(() => { useEffect(() => {
@@ -30,12 +35,14 @@ function RundownTable() {
const isLoading = !customFields || customFieldStatus === 'pending'; const isLoading = !customFields || customFieldStatus === 'pending';
return ( return (
<CuesheetDnd columns={columns} tableRoot='editor'> <EntryActionsProvider actions={actions}>
{isLoading ? ( <CuesheetDnd columns={columns} tableRoot='editor'>
<EmptyPage text='Loading...' /> {isLoading ? (
) : ( <EmptyPage text='Loading...' />
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' /> ) : (
)} <CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd> )}
</CuesheetDnd>
</EntryActionsProvider>
); );
} }
@@ -2,7 +2,7 @@ import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } fro
import { MouseEvent } from 'react'; import { MouseEvent } from 'react';
import { create } from 'zustand'; import { create } from 'zustand';
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants'; import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils'; import { isMacOS } from '../../common/utils/deviceUtils';
@@ -137,12 +137,9 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
})); }));
function getLoadedRundownData() { function getLoadedRundownData() {
const loadedRundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded; const rundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
if (loadedRundownId) { if (!rundownId) return undefined;
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(loadedRundownId)); return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(rundownId));
}
return ontimeQueryClient.getQueryData<Rundown>(CURRENT_RUNDOWN_QUERY_KEY);
} }
export function getSelectionMode(event: MouseEvent): SelectionMode { export function getSelectionMode(event: MouseEvent): SelectionMode {
@@ -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',
@@ -7,6 +7,7 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
@@ -140,25 +141,23 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
return () => clearInterval(paginator.current); return () => clearInterval(paginator.current);
}, [cycleInterval, numPages, stopCycle, visiblePage]); }, [cycleInterval, numPages, stopCycle, visiblePage]);
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
// we want to show the event after the current // we want to show the event after the current
const viewEvents = events.slice(selectedEventIndex + 1); const viewEvents = useMemo(() => {
selectedEventIndex = 0; const selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
return (events as ExtendedEntry<OntimeEvent>[]).slice(selectedEventIndex + 1);
}, [events, selectedEventId]);
return ( const value = useMemo(() => {
<ScheduleContext return {
value={{ events: viewEvents,
events: viewEvents as ExtendedEntry<OntimeEvent>[], selectedEventId,
selectedEventId, numPages,
numPages, visiblePage,
visiblePage, containerRef,
containerRef, };
}} }, [viewEvents, selectedEventId, numPages, visiblePage, containerRef]);
>
{children} return <ScheduleContext value={value}>{children}</ScheduleContext>;
</ScheduleContext>
);
}; };
export const useSchedule = () => { export const useSchedule = () => {
+106 -3
View File
@@ -59,6 +59,7 @@ $item-height: 3.5rem;
right: $view-inline-padding; right: $view-inline-padding;
display: flex; display: flex;
align-items: center;
justify-content: end; justify-content: end;
gap: 2rem; gap: 2rem;
@@ -72,14 +73,27 @@ $item-height: 3.5rem;
} }
/* ========================= LIST ========================*/ /* ========================= LIST ========================*/
.empty-container { .empty-state {
min-height: min(56vh, 36rem);
padding-top: clamp(4rem, 14vh, 9rem);
text-align: center; text-align: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
button { button {
margin-top: 2rem; margin-top: 1.75rem;
}
}
.empty-state__content {
max-width: none;
span {
max-width: none;
white-space: nowrap;
font-size: clamp(1.5rem, 4vw, 2.25rem);
line-height: 1.1;
} }
} }
@@ -88,7 +102,7 @@ $item-height: 3.5rem;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
padding-bottom: 60vh; padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
} }
/* ====================== LIST-ITEM ======================*/ /* ====================== LIST-ITEM ======================*/
@@ -104,6 +118,7 @@ $item-height: 3.5rem;
column-gap: 1rem; column-gap: 1rem;
background-color: var(--card-background-color-override, $viewer-card-bg-color); background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding-right: 1rem; padding-right: 1rem;
border: 1px solid transparent;
border-radius: $element-border-radius; border-radius: $element-border-radius;
&:hover { &:hover {
@@ -115,18 +130,41 @@ $item-height: 3.5rem;
.sub--selected { .sub--selected {
background: $blue-700; background: $blue-700;
box-shadow: inset 0 0 0 1px $blue-500;
} }
.sub--live { .sub--live {
background-color: $active-green; background-color: $active-green;
box-shadow: inset 0 0 0 1px color-mix(in srgb, $active-green 75%, $ui-white);
} }
.sub--armed { .sub--armed {
background-color: $gray-1325; background-color: $gray-1325;
} }
.sub--in-group {
position: relative;
margin-left: 1rem;
&::before {
content: '';
position: absolute;
left: -0.5rem;
top: -0.25rem;
bottom: -0.25rem;
width: 2px;
border-radius: 1px;
background: var(--group-color, transparent);
}
.sub__binder {
box-shadow: inset 3px 0 0 var(--group-color, transparent);
}
}
.sub__binder { .sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color)); background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
border-radius: $element-border-radius 0 0 $element-border-radius;
grid-area: binder; grid-area: binder;
} }
@@ -138,6 +176,8 @@ $item-height: 3.5rem;
gap: 0.25em; gap: 0.25em;
font-size: $timer-label-size; font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
white-space: nowrap;
font-variant-numeric: tabular-nums;
} }
.sub__schedule--delayed { .sub__schedule--delayed {
@@ -156,22 +196,73 @@ $item-height: 3.5rem;
color: $playback-under; color: $playback-under;
} }
.sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
background:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
var(--card-background-color-override, $viewer-card-bg-color);
.sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
box-shadow: none;
}
.sub__title {
font-weight: 700;
}
.sub__schedule,
.sub__status,
.sub__label {
color: var(--secondary-color-override, $viewer-secondary-color);
}
}
.sub--group.sub--selected {
box-shadow: inset 0 0 0 1px $blue-500;
background:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 22%, transparent), transparent 42%),
$blue-700;
.sub__schedule,
.sub__status,
.sub__label,
.sub__eyebrow {
color: $ui-white;
}
}
.sub__title { .sub__title {
grid-area: title; grid-area: title;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
min-width: 0;
font-size: $title-font-size; font-size: $title-font-size;
line-height: 1.1em; line-height: 1.1em;
overflow-wrap: anywhere;
}
.sub__eyebrow {
display: block;
margin-bottom: 0.15rem;
color: var(--secondary-color-override, $viewer-secondary-color);
font-size: $timer-label-size;
font-weight: 700;
line-height: 1;
text-transform: uppercase;
} }
.sub__secondary { .sub__secondary {
grid-area: secondary; grid-area: secondary;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
min-width: 0;
font-size: $base-font-size; font-size: $base-font-size;
line-height: 1.1em; line-height: 1.1em;
color: var(--label-color-override, $viewer-label-color);
// allow multi-line text but trim before // allow multi-line text but trim before
white-space: pre-line; white-space: pre-line;
overflow-wrap: anywhere;
} }
.sub__status { .sub__status {
@@ -181,6 +272,7 @@ $item-height: 3.5rem;
font-size: $timer-label-size; font-size: $timer-label-size;
text-align: right; text-align: right;
text-transform: uppercase; text-transform: uppercase;
white-space: nowrap;
} }
.sub__label { .sub__label {
@@ -190,6 +282,7 @@ $item-height: 3.5rem;
font-size: $timer-label-size; font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color); color: var(--label-color-override, $viewer-label-color);
text-align: right; text-align: right;
white-space: nowrap;
} }
.sub__timer { .sub__timer {
@@ -199,6 +292,16 @@ $item-height: 3.5rem;
line-height: 1.1em; line-height: 1.1em;
font-weight: 600; font-weight: 600;
text-align: right; text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.selection-count {
color: var(--label-color-override, $viewer-label-color);
font-size: $timer-label-size;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
} }
/* ====================== MODIFIERS ======================*/ /* ====================== MODIFIERS ======================*/
+42 -18
View File
@@ -1,4 +1,14 @@
import { EntryId, OntimeEvent, OntimeView, PlayableEvent, isOntimeEvent, isPlayableEvent } from 'ontime-types'; import {
EntryId,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeView,
PlayableEvent,
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
@@ -15,7 +25,7 @@ import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../common/loader/Loader'; import Loader from '../common/loader/Loader';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getCountdownOptions, useCountdownOptions } from './countdown.options'; import { getCountdownOptions, useCountdownOptions } from './countdown.options';
import { getOrderedSubscriptions } from './countdown.utils'; import { getOrderedSubscriptions, resolveSubscriptionTarget } from './countdown.utils';
import CountdownSelect from './CountdownSelect'; import CountdownSelect from './CountdownSelect';
import CountdownSubscriptions from './CountdownSubscriptions'; import CountdownSubscriptions from './CountdownSubscriptions';
import SingleEventCountdown from './SingleEventCountdown'; import SingleEventCountdown from './SingleEventCountdown';
@@ -45,13 +55,19 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
// gather rundown data // gather subscribable items: playable events and groups that contain at least one playable child
const playableEvents = rundownData.filter((entry): entry is ExtendedEntry<PlayableEvent> => { const candidates = rundownData.filter((entry): entry is ExtendedEntry<PlayableEvent | OntimeGroup> => {
return isOntimeEvent(entry) && isPlayableEvent(entry); if (isOntimeEvent(entry)) {
return isPlayableEvent(entry);
}
if (isOntimeGroup(entry)) {
return rundownData.some((item) => isOntimeEvent(item) && isPlayableEvent(item) && item.parent === entry.id);
}
return false;
}); });
// gather presentation data // gather presentation data
const hasEvents = playableEvents.length > 0; const hasEvents = candidates.length > 0;
// gather option data // gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
@@ -69,15 +85,20 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
<CountdownClock /> <CountdownClock />
</div> </div>
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />} {!hasEvents && (
<div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
</div>
)}
{hasEvents && editMode && ( {hasEvents && editMode && (
<CountdownSelect events={playableEvents} subscriptions={subscriptions} disableEdit={() => setEditMode(false)} /> <CountdownSelect events={candidates} subscriptions={subscriptions} disableEdit={() => setEditMode(false)} />
)} )}
{hasEvents && !editMode && ( {hasEvents && !editMode && (
<CountdownContents <CountdownContents
playableEvents={playableEvents} candidates={candidates}
rundownData={rundownData}
subscriptions={subscriptions} subscriptions={subscriptions}
goToEditMode={() => setEditMode(true)} goToEditMode={() => setEditMode(true)}
/> />
@@ -87,19 +108,20 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
} }
interface CountdownContentsProps { interface CountdownContentsProps {
playableEvents: ExtendedEntry<OntimeEvent>[]; candidates: ExtendedEntry<OntimeEvent | OntimeGroup>[];
rundownData: ExtendedEntry<OntimeEntry>[];
subscriptions: EntryId[]; subscriptions: EntryId[];
goToEditMode: () => void; goToEditMode: () => void;
} }
function CountdownContents({ playableEvents, subscriptions, goToEditMode }: CountdownContentsProps) { function CountdownContents({ candidates, rundownData, subscriptions, goToEditMode }: CountdownContentsProps) {
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const { hidePast } = useCountdownOptions(); const { hidePast } = useCountdownOptions();
if (subscriptions.length === 0) { if (subscriptions.length === 0) {
return ( return (
<div className='empty-container'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-container' /> <Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -107,13 +129,15 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
); );
} }
const subscribedEvents = getOrderedSubscriptions(subscriptions, playableEvents); const subscribedEvents = getOrderedSubscriptions(subscriptions, candidates)
.map((entry) => resolveSubscriptionTarget(entry, rundownData))
.filter((target): target is NonNullable<typeof target> => target !== null);
const eventsToShow = !hidePast ? subscribedEvents : subscribedEvents.filter((event) => !event.isPast); const eventsToShow = !hidePast ? subscribedEvents : subscribedEvents.filter((event) => !event.isPast);
if (subscribedEvents.length === 0) { if (subscribedEvents.length === 0) {
return ( return (
<div className='empty-container'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-container' /> <Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Button variant='primary' size='xlarge' onClick={goToEditMode}> <Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add <IoAdd /> Add
</Button> </Button>
@@ -129,8 +153,8 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
if (eventsToShow.length === 0) { if (eventsToShow.length === 0) {
return ( return (
<div className='empty-container'> <div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-container' /> <Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
</div> </div>
); );
} }
@@ -1,4 +1,4 @@
import { EntryId, PlayableEvent } from 'ontime-types'; import { EntryId, OntimeEvent, OntimeGroup, isOntimeGroup } from 'ontime-types';
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5'; import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -12,7 +12,7 @@ import { makeSubscriptionsUrl } from './countdown.utils';
import './Countdown.scss'; import './Countdown.scss';
interface CountdownSelectProps { interface CountdownSelectProps {
events: ExtendedEntry<PlayableEvent>[]; events: ExtendedEntry<OntimeEvent | OntimeGroup>[];
subscriptions: EntryId[]; subscriptions: EntryId[];
disableEdit: () => void; disableEdit: () => void;
} }
@@ -52,13 +52,15 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
return ( return (
<div className='list-container'> <div className='list-container'>
{events.map((event, index) => { {events.map((event) => {
const title = event.title || '{no title}'; const title = event.title || '{no title}';
const isSelected = selectedIds.has(event.id); const isSelected = selectedIds.has(event.id);
const isGroup = isOntimeGroup(event);
const isGroupedEvent = !isGroup && Boolean(event.parent);
return ( return (
<div <div
key={index} key={event.id}
role='button' role='button'
tabIndex={0} tabIndex={0}
onClick={() => toggleSelect(event.id)} onClick={() => toggleSelect(event.id)}
@@ -68,21 +70,33 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
e.stopPropagation(); e.stopPropagation();
} }
}} }}
className={cx(['sub', isSelected && 'sub--selected'])} className={cx([
'sub',
isSelected && 'sub--selected',
isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
> >
<div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} /> <div
className='sub__binder'
style={{ '--user-color': event?.colour ?? '', '--group-color': event.groupColour ?? 'transparent' }}
/>
<div className='sub__schedule'> <div className='sub__schedule'>
<ClockTime value={event.timeStart} preferredFormat12='h:mm a' preferredFormat24='HH:mm' /> <ClockTime value={event.timeStart} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
<ClockTime value={event.timeEnd} preferredFormat12='h:mm a' preferredFormat24='HH:mm' /> <ClockTime value={event.timeEnd} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
</div> </div>
<div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div> <div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div>
<div className='sub__title'>{title}</div> <div className='sub__title'>
{isGroup && <span className='sub__eyebrow'>Group</span>}
{title}
</div>
</div> </div>
); );
})} })}
<div className='fab-container'> <div className='fab-container'>
<div className='selection-count'>{selected.length} selected</div>
<Button variant='subtle' size='xlarge' onClick={disableEdit}> <Button variant='subtle' size='xlarge' onClick={disableEdit}>
<IoArrowBack /> Go back <IoArrowBack /> Go back
</Button> </Button>
@@ -19,6 +19,7 @@ import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options'; import { useCountdownOptions } from './countdown.options';
import { import {
CountdownEvent, CountdownEvent,
CountdownTarget,
extendEventData, extendEventData,
getIsLive, getIsLive,
isOutsideRange, isOutsideRange,
@@ -30,7 +31,7 @@ import {
import './Countdown.scss'; import './Countdown.scss';
interface CountdownSubscriptionsProps { interface CountdownSubscriptionsProps {
subscribedEvents: ExtendedEntry<OntimeEvent>[]; subscribedEvents: CountdownTarget[];
goToEditMode: () => void; goToEditMode: () => void;
} }
@@ -98,22 +99,39 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return ( return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{subscribedEvents.map((event) => { {subscribedEvents.map((event) => {
const secondaryData = getPropertyValue(event, secondarySource); // while a group is live, surface the running event's title as the secondary line
const isLive = getIsLive(event.id, selectedEventId, playback); const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
const isArmed = !isLive && event.id === selectedEventId; const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData); const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const displayTitle = getPropertyValue(event, mainSource ?? 'title'); const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return ( return (
<div <div
key={event.id} key={event.id}
ref={isLive ? selectedRef : undefined} ref={isLive ? selectedRef : undefined}
className={cx(['sub', isLive && 'sub--live', isArmed && 'sub--armed'])} className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue} data-testid={event.cue}
> >
<div className='sub__binder' style={{ '--user-color': event.colour }} /> <div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} /> <ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} /> <SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>{displayTitle}</div> <div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>} {secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div> </div>
); );
@@ -11,13 +11,13 @@ import { cx } from '../../common/utils/styleUtils';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils'; import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options'; import { useCountdownOptions } from './countdown.options';
import { useSubscriptionDisplayData } from './countdown.utils'; import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions'; import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss'; import './SingleEventCountdown.scss';
interface SingleEventCountdownProps { interface SingleEventCountdownProps {
subscribedEvent: ExtendedEntry<OntimeEvent>; subscribedEvent: CountdownTarget;
goToEditMode: () => void; goToEditMode: () => void;
} }
@@ -38,11 +38,13 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
mode, mode,
}); });
const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null }; const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt }; const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title'); const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing; const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
const secondaryData = getPropertyValue(subscribedEvent, secondarySource); // while a group is live, surface the running event's title as the secondary line
const liveTitle = subscribedEvent.isGroup && subscribedEvent.liveEntry ? subscribedEvent.liveEntry.title : undefined;
const secondaryData = liveTitle ?? getPropertyValue(subscribedEvent, secondarySource);
return ( return (
<div className='single-container' data-testid='countdown-event'> <div className='single-container' data-testid='countdown-event'>
@@ -80,7 +80,7 @@ export const getCountdownOptions = (
{ {
id: 'sub', id: 'sub',
title: 'Event subscription', title: 'Event subscription',
description: 'The events to follow', description: 'The events and groups to follow',
values: persistedSubscriptions, values: persistedSubscriptions,
type: 'persist', type: 'persist',
}, },
@@ -0,0 +1,128 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { resolveSubscriptionTarget } from './countdown.utils';
/**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
* Only the fields exercised by resolveSubscriptionTarget are provided; the rest are cast away.
*/
function makeEvent(patch: Partial<ExtendedEntry<OntimeEvent>>): ExtendedEntry<OntimeEvent> {
return {
id: 'event',
type: SupportedEntry.Event,
title: 'event title',
cue: '1',
colour: '',
skip: false,
parent: null,
timeStart: 0,
timeEnd: 0,
duration: 0,
delay: 0,
dayOffset: 0,
gap: 0,
countToEnd: false,
custom: {},
note: '',
// metadata
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
isPast: false,
...patch,
} as ExtendedEntry<OntimeEvent>;
}
function makeGroup(patch: Partial<ExtendedEntry<OntimeGroup>>): ExtendedEntry<OntimeGroup> {
return {
id: 'group',
type: SupportedEntry.Group,
title: 'group title',
colour: '#abcdef',
note: 'group note',
entries: [],
duration: 0,
custom: {},
timeStart: 0,
timeEnd: 0,
isPast: false,
isLoaded: false,
...patch,
} as ExtendedEntry<OntimeGroup>;
}
describe('resolveSubscriptionTarget()', () => {
it('returns events unchanged', () => {
const event = makeEvent({ id: 'e1' });
const result = resolveSubscriptionTarget(event, [event]);
expect(result).toBe(event);
});
it('derives group timing from the first playable child while keeping the group identity', () => {
const group = makeGroup({ id: 'g1', title: 'Session A', colour: '#123456', duration: 5000 });
const child1 = makeEvent({ id: 'c1', parent: 'g1', timeStart: 1000, delay: 100, dayOffset: 0, title: 'Pres 1' });
const child2 = makeEvent({ id: 'c2', parent: 'g1', timeStart: 3000, title: 'Pres 2' });
const flat: ExtendedEntry<OntimeEntry>[] = [group, child1, child2];
const result = resolveSubscriptionTarget(group, flat);
expect(result).not.toBeNull();
// group identity and display
expect(result?.id).toBe('g1');
expect(result?.title).toBe('Session A');
expect(result?.colour).toBe('#123456');
expect(result?.isGroup).toBe(true);
// timing comes from the first child, duration from the group
expect(result?.timeStart).toBe(1000);
expect(result?.delay).toBe(100);
expect(result?.duration).toBe(5000);
expect(result?.countToEnd).toBe(false);
// state checks target the first child before any child is loaded
expect(result?.targetId).toBe('c1');
// report lookup targets the last child (session end)
expect(result?.reportId).toBe('c2');
});
it('returns null for a group with no playable children', () => {
const group = makeGroup({ id: 'g1' });
const result = resolveSubscriptionTarget(group, [group]);
expect(result).toBeNull();
});
it('skips skipped children when picking the first child', () => {
const group = makeGroup({ id: 'g1' });
const skipped = {
...makeEvent({ id: 'c0', parent: 'g1', timeStart: 500 }),
skip: true,
} as ExtendedEntry<OntimeEntry>;
const playable = makeEvent({ id: 'c1', parent: 'g1', timeStart: 1500 });
const result = resolveSubscriptionTarget(group, [group, skipped, playable]);
expect(result?.timeStart).toBe(1500);
expect(result?.reportId).toBe('c1');
});
it('is live while any child is loaded and not past', () => {
const group = makeGroup({ id: 'g1' });
const child1 = makeEvent({ id: 'c1', parent: 'g1', isPast: true });
const child2 = makeEvent({ id: 'c2', parent: 'g1', isLoaded: true });
const child3 = makeEvent({ id: 'c3', parent: 'g1' });
const result = resolveSubscriptionTarget(group, [group, child1, child2, child3]);
expect(result?.isLoaded).toBe(true);
expect(result?.isPast).toBe(false);
expect(result?.liveEntry?.id).toBe('c2');
});
it('is past only once the last child is past and nothing is loaded', () => {
const group = makeGroup({ id: 'g1' });
const child1 = makeEvent({ id: 'c1', parent: 'g1', isPast: true });
const child2 = makeEvent({ id: 'c2', parent: 'g1', isPast: true });
const result = resolveSubscriptionTarget(group, [group, child1, child2]);
expect(result?.isLoaded).toBe(false);
expect(result?.isPast).toBe(true);
expect(result?.targetId).toBe('c1');
expect(result?.liveEntry).toBeNull();
});
});
@@ -1,4 +1,17 @@
import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types'; import {
EntryId,
MaybeNumber,
OffsetMode,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeReport,
Playback,
PlayableEvent,
isOntimeEvent,
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils'; import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket'; import { useCountdownSocket } from '../../common/hooks/useSocket';
@@ -40,7 +53,7 @@ export const timerProgress: TimerMessage = {
* Handles events in different days but disregards whether an event has actually played * Handles events in different days but disregards whether an event has actually played
*/ */
export function useSubscriptionDisplayData( export function useSubscriptionDisplayData(
subscribedEvent: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number }, subscribedEvent: CountdownTarget & { endedAt: MaybeNumber; expectedStart: number },
): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } { ): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } {
const { playback, current, clock } = useCountdownSocket(); const { playback, current, clock } = useCountdownSocket();
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
@@ -67,7 +80,11 @@ export function useSubscriptionDisplayData(
return { return {
status: 'live', status: 'live',
statusDisplay: getLocalizedString(timerProgress['live']), statusDisplay: getLocalizedString(
subscribedEvent.parent || (subscribedEvent.isGroup && subscribedEvent.liveEntry)
? 'countdown.group_running'
: timerProgress['live'],
),
timeDisplay: bigDuration(current ?? 0), timeDisplay: bigDuration(current ?? 0),
}; };
} }
@@ -168,10 +185,75 @@ export function isOutsideRange(a: number, b: number): boolean {
return Math.abs(a - b) > MILLIS_PER_MINUTE; return Math.abs(a - b) > MILLIS_PER_MINUTE;
} }
export type CountdownEvent = ExtendedEntry<OntimeEvent> & { expectedStart: number; endedAt: MaybeNumber }; /**
* A subscription target normalised to the event-shaped object the countdown display consumes.
* For events this is a pass-through; for groups it carries the group identity and display data
* while deriving timing from the group's first playable child (see resolveSubscriptionTarget).
*/
export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
isGroup?: boolean;
targetId?: EntryId; // child entry used for countdown/live/armed state checks
reportId?: EntryId; // entry used for the report lookup (e.g. last child of a group)
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
};
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
/**
* Resolves a subscription (event or group) into an event-shaped countdown target.
* - Events are returned unchanged.
* - Groups count down to their first playable child while displaying the group identity.
* Status is group-aware: live while any child is loaded, past only once the whole group has finished.
* Returns null when a group has no playable children (nothing to count down to).
*/
export function resolveSubscriptionTarget(
entry: ExtendedEntry<OntimeEvent | OntimeGroup>,
flatRundown: ExtendedEntry<OntimeEntry>[],
): CountdownTarget | null {
if (!isOntimeGroup(entry)) {
return entry;
}
const children = flatRundown.filter(
(item): item is ExtendedEntry<PlayableEvent> =>
isOntimeEvent(item) && isPlayableEvent(item) && item.parent === entry.id,
);
if (children.length === 0) {
return null;
}
const firstChild = children[0];
const lastChild = children[children.length - 1];
const liveEntry = children.find((child) => child.isLoaded) ?? null;
const isLoaded = liveEntry !== null;
const isPast = !isLoaded && lastChild.isPast;
return {
// timing derives from the first playable child
...firstChild,
// group identity and display data
id: entry.id,
parent: null,
title: entry.title,
colour: entry.colour,
note: entry.note,
custom: entry.custom,
duration: entry.duration,
countToEnd: false,
// group-aware status
isLoaded,
isPast,
// group markers
isGroup: true,
targetId: firstChild.id,
reportId: lastChild.id,
liveEntry,
};
}
export function extendEventData( export function extendEventData(
event: ExtendedEntry<OntimeEvent>, event: CountdownTarget,
currentDay: number, currentDay: number,
actualStart: MaybeNumber, actualStart: MaybeNumber,
plannedStart: MaybeNumber, plannedStart: MaybeNumber,
@@ -189,6 +271,6 @@ export function extendEventData(
offset, offset,
mode, mode,
}); });
const { endedAt } = reportData[event.id] ?? { endedAt: null }; const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
return { ...event, expectedStart, endedAt }; return { ...event, expectedStart, endedAt };
} }
@@ -14,3 +14,7 @@
'table'; 'table';
color: $ui-white; color: $ui-white;
} }
.rundownSelect {
min-width: min(20rem, calc(100vw - 6rem));
}
@@ -4,28 +4,34 @@ import { IoApps } from 'react-icons/io5';
import IconButton from '../../common/components/buttons/IconButton'; import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useEntryActions } from '../../common/hooks/useEntryAction'; import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals'; import { getIsNavigationLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview'; import CuesheetOverview from '../../features/overview/CuesheetOverview';
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal'; import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableWrapper from './CuesheetTableWrapper'; import CuesheetTableWrapper from './CuesheetTableWrapper';
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss'; import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() { export default function CuesheetPage() {
'use memo';
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const entryActions = useEntryActions(); const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
const actions = useScopedEntryActions(source.rundownId);
useWindowTitle('Cuesheet'); useWindowTitle('Cuesheet');
const isLocked = getIsNavigationLocked(); const isLocked = getIsNavigationLocked();
return ( return (
<EntryActionsProvider actions={entryActions}> <EntryActionsProvider actions={actions}>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} /> <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<EntryEditModal /> <EntryEditModal rundown={source.rundown} />
<div className={styles.tableWrapper} data-testid='cuesheet'> <div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetOverview> <CuesheetOverview>
{!isLocked && ( {!isLocked && (
@@ -35,7 +41,13 @@ export default function CuesheetPage() {
)} )}
</CuesheetOverview> </CuesheetOverview>
<CuesheetProgress /> <CuesheetProgress />
<CuesheetTableWrapper /> <CuesheetTableWrapper
source={source}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</div> </div>
</EntryActionsProvider> </EntryActionsProvider>
); );
@@ -1,18 +1,40 @@
import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react'; import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext'; import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields'; import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
import { AppMode } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable'; import CuesheetTable from './cuesheet-table/CuesheetTable';
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy'; import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
interface CuesheetTableWrapperProps {
source: RundownSource;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
projectRundowns: ProjectRundown[];
}
export default memo(CuesheetTableWrapper); export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper() { function CuesheetTableWrapper({
const { data: customFields, status: customFieldStatus } = useCustomFields(); source,
selectedRundownId,
setSelectedRundownId,
loadedRundownId,
projectRundowns,
}: CuesheetTableWrapperProps) {
const preset = use(PresetContext); const preset = use(PresetContext);
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset); const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields, status: customFieldStatus } = useCustomFields();
const columns = useMemo( const columns = useMemo(
() => makeCuesheetColumns(customFields, cuesheetMode, preset), () => makeCuesheetColumns(customFields, cuesheetMode, preset),
@@ -28,11 +50,66 @@ function CuesheetTableWrapper() {
) : ( ) : (
<CuesheetTable <CuesheetTable
columns={columns} columns={columns}
source={source}
cuesheetMode={cuesheetMode} cuesheetMode={cuesheetMode}
tableRoot='cuesheet' tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode} setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
cuesheetMode={cuesheetMode}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</>
}
/> />
)} )}
</CuesheetDnd> </CuesheetDnd>
); );
} }
interface RundownSelectProps {
cuesheetMode: AppMode;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
projectRundowns: ProjectRundown[];
}
function RundownSelect({
cuesheetMode,
projectRundowns,
loadedRundownId,
selectedRundownId,
setSelectedRundownId,
}: RundownSelectProps) {
'use memo';
const options = projectRundowns.map(({ id, title }) => ({
value: id,
label: loadedRundownId === id ? `${title} (loaded)` : title,
}));
options.unshift({
value: FOLLOW_LOADED_RUNDOWN_ID,
label: 'Follow loaded', // TODO: Better wording and maybe icon? and translation
});
return (
<div className={styles.rundownSelect}>
<Select
value={selectedRundownId ?? undefined}
options={options}
onValueChange={(value) => {
if (value) {
setSelectedRundownId(value);
}
}}
disabled={cuesheetMode === AppMode.Run}
fluid
/>
</div>
);
}
@@ -20,6 +20,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-read-only', alias: 'cuesheet-read-only',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: '-', write: '-',
@@ -41,6 +42,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-flag', alias: 'cuesheet-flag',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'full', read: 'full',
write: 'flag', write: 'flag',
@@ -62,6 +64,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-default', alias: 'cuesheet-default',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
}; };
const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit); const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
@@ -76,6 +79,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
alias: 'cuesheet-granular', alias: 'cuesheet-granular',
target: OntimeView.Cuesheet, target: OntimeView.Cuesheet,
search: '', search: '',
displayInNav: false,
options: { options: {
read: 'cue,title', read: 'cue,title',
write: 'title', write: 'title',
@@ -1,32 +0,0 @@
import { parseField } from '../cuesheet.utils';
describe('parseField()', () => {
it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
const testData1 = 1000;
const testData2 = 60000;
const testData3 = 600000;
expect(parseField('timeStart', testData1)).toBe('00:00:01');
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
expect(parseField('duration', testData3)).toBe('00:10:00');
});
it('returns an empty string on undefined fields', () => {
// @ts-expect-error -- testing user data with missing fields
expect(parseField('title')).toBe('');
});
describe('simply returns any other value in any other field', () => {
const testFields = [
{ field: 'nothing', value: '123' },
{ field: 'title', value: 'test' },
{ field: 'note', value: 'test' },
{ field: 'colour', value: 'test' },
];
testFields.forEach((testCase) => {
test(`${testCase.field}:${testCase.value}`, () => {
expect(parseField(testCase.field, testCase.value)).toBe(testCase.value);
});
});
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { AppMode } from '../../../ontimeConfig';
import { getEffectiveCuesheetMode } from '../useApplyCuesheetPolicy';
describe('getEffectiveCuesheetMode()', () => {
it('honors the stored mode when the user can change mode on the current rundown', () => {
const permissions = { canChangeMode: true };
expect(getEffectiveCuesheetMode(permissions, AppMode.Edit, { canRunMode: true })).toBe(AppMode.Edit);
expect(getEffectiveCuesheetMode(permissions, AppMode.Run, { canRunMode: true })).toBe(AppMode.Run);
});
it('forces Run mode when the preset forbids mode changes', () => {
const permissions = { canChangeMode: false };
expect(getEffectiveCuesheetMode(permissions, AppMode.Edit, { canRunMode: true })).toBe(AppMode.Run);
});
it('forces Edit mode for background rundowns without changing the permission policy', () => {
const permissions = { canChangeMode: true };
expect(getEffectiveCuesheetMode(permissions, AppMode.Run, { canRunMode: false })).toBe(AppMode.Edit);
});
});
@@ -0,0 +1,21 @@
import {
FOLLOW_LOADED_RUNDOWN_ID,
getCuesheetRundownStorageKey,
resolveSelectedRundownId,
} from '../useCuesheetRundownSelection';
describe('useCuesheetRundownSelection helpers', () => {
it('builds a project-scoped storage key', () => {
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
'cuesheet-selected-rundown:http://localhost:4001:My Project',
);
});
it('falls back to the follow loaded rundown when the stored selection is missing', () => {
expect(resolveSelectedRundownId('missing', new Set(['loaded', 'other']))).toBe(FOLLOW_LOADED_RUNDOWN_ID);
});
it('keeps the stored selection when it still exists in the current project', () => {
expect(resolveSelectedRundownId('other', new Set(['loaded', 'other']))).toBe('other');
});
});
@@ -1,11 +1,16 @@
import { Rundown } from 'ontime-types';
import { memo } from 'react'; import { memo } from 'react';
import Modal from '../../../common/components/modal/Modal'; import Modal from '../../../common/components/modal/Modal';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor'; import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditModal } from './useEditModal'; import { useEditModal } from './useEditModal';
interface EntryEditModalProps {
rundown: Rundown;
}
export default memo(EntryEditModal); export default memo(EntryEditModal);
function EntryEditModal() { function EntryEditModal({ rundown }: EntryEditModalProps) {
const entryId = useEditModal((state) => state.selectedEntryId); const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection); const closeModal = useEditModal((state) => state.clearSelection);
@@ -19,7 +24,7 @@ function EntryEditModal() {
onClose={closeModal} onClose={closeModal}
title='Edit entry' title='Edit entry'
showCloseButton showCloseButton
bodyElements={<CuesheetEntryEditor entryId={entryId} />} bodyElements={<CuesheetEntryEditor entryId={entryId} rundown={rundown} />}
/> />
); );
} }
@@ -1,7 +1,7 @@
import { useTableNav } from '@table-nav/react'; import { useTableNav } from '@table-nav/react';
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { OntimeEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
ContextProp, ContextProp,
ItemProps, ItemProps,
@@ -14,21 +14,20 @@ import {
import EmptyPage from '../../../common/components/state/EmptyPage'; import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody'; import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown'; import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useSelectedEventId } from '../../../common/hooks/useSocket';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import EditorTableSettings from '../../../features/rundown/rundown-table/EditorTableSettings';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options'; import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection'; import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../cuesheet.options'; import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader'; import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
import DelayRow from './cuesheet-table-elements/DelayRow'; import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow'; import EventRow from './cuesheet-table-elements/EventRow';
import GroupRow from './cuesheet-table-elements/GroupRow'; import GroupRow from './cuesheet-table-elements/GroupRow';
import MilestoneRow from './cuesheet-table-elements/MilestoneRow'; import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
import TableMenu from './cuesheet-table-menu/TableMenu'; import TableMenu from './cuesheet-table-menu/TableMenu';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings'; import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager'; import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
import style from './CuesheetTable.module.scss'; import style from './CuesheetTable.module.scss';
@@ -36,30 +35,41 @@ import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = { type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[]; columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode; cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode;
}; };
type EditorCuesheetTableProps = CuesheetTableBaseProps & { type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor'; tableRoot: 'editor';
setCuesheetMode?: undefined; setCuesheetMode?: undefined;
isCurrentRundown?: undefined;
}; };
type ViewCuesheetTableProps = CuesheetTableBaseProps & { type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet'; tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void; setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
}; };
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps; type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) { export default function CuesheetTable({
const { data, status } = useFlatRundownWithMetadata(); columns,
const { updateEntry, updateTimer } = useEntryActionsContext(); cuesheetMode,
source,
tableRoot,
setCuesheetMode,
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { flatRundown, status, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions; const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
const showDelayedTimes = useOptions((state) => state.showDelayedTimes); const optionsStore = useOptions();
const hideTableSeconds = useOptions((state) => state.hideTableSeconds); const { showDelayedTimes, hideTableSeconds, hideIndexColumn } = optionsStore;
const hideIndexColumn = useOptions((state) => state.hideIndexColumn);
const selectedEventId = useSelectedEventId();
const cursor = useEventSelection((state) => state.cursor); const cursor = useEventSelection((state) => state.cursor);
const setScrollHandler = useEventSelection((state) => state.setScrollHandler); const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
@@ -70,7 +80,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
() => ({ () => ({
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => { handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
// check if value is the same // check if value is the same
const event = data[rowIndex]; const event = flatRundown[rowIndex];
if (!event) { if (!event) {
return; return;
@@ -101,7 +111,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
hideIndexColumn, hideIndexColumn,
}, },
}), }),
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer], [cuesheetMode, flatRundown, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
); );
const { columnOrder, resetColumnOrder } = useColumnOrder(columns, tableRoot); const { columnOrder, resetColumnOrder } = useColumnOrder(columns, tableRoot);
@@ -109,7 +119,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot); const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
const table = useReactTable({ const table = useReactTable({
data, data: flatRundown,
columns, columns,
columnResizeMode: 'onChange', columnResizeMode: 'onChange',
state: { state: {
@@ -137,13 +147,13 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
return; return;
} }
const eventIndex = data.findIndex((event) => event.id === selectedEventId); const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId);
if (eventIndex === -1) { if (eventIndex === -1) {
return; return;
} }
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 }); virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
}, [cuesheetMode, data, selectedEventId]); }, [cuesheetMode, flatRundown, selectedEventId]);
// Provide an imperative scroll handler for explicit jumps (finder/keyboard) // Provide an imperative scroll handler for explicit jumps (finder/keyboard)
useEffect(() => { useEffect(() => {
@@ -152,7 +162,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
return; return;
} }
const eventIndex = data.findIndex((event) => event.id === entryId); const eventIndex = flatRundown.findIndex((event) => event.id === entryId);
if (eventIndex === -1) { if (eventIndex === -1) {
return; return;
} }
@@ -165,7 +175,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
return () => { return () => {
setScrollHandler(null); setScrollHandler(null);
}; };
}, [data, setScrollHandler]); }, [flatRundown, setScrollHandler]);
/** /**
* To improve performance on resizing, we memoise the column sizes * To improve performance on resizing, we memoise the column sizes
@@ -194,8 +204,9 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
listeners, listeners,
rows, rows,
table, table,
handleAddNew: canCreateEntries ? (type: SupportedEntry) => addEntry({ type }) : undefined,
}), }),
[columnSizeVars, cursor, listeners, rows, table], [columnSizeVars, cursor, listeners, rows, table, addEntry, canCreateEntries],
); );
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []); const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
@@ -217,7 +228,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
}); });
}, [cuesheetMode, hideIndexColumn, table]); }, [cuesheetMode, hideIndexColumn, table]);
const isLoading = !data || status === 'pending'; const isLoading = !flatRundown || status === 'pending';
if (isLoading) { if (isLoading) {
return <EmptyPage text='Loading...' />; return <EmptyPage text='Loading...' />;
@@ -225,26 +236,27 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
return ( return (
<> <>
{tableRoot === 'editor' ? ( <CuesheetTableHeaderToolbar
<EditorTableSettings columns={allLeafColumns}
columns={allLeafColumns} optionsStore={optionsStore}
handleResetResizing={resetColumnResizing} handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder} handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible} handleClearToggles={setAllVisible}
/> insertElement={insertElement}
) : ( modeControls={
<CuesheetTableSettings tableRoot === 'cuesheet'
columns={allLeafColumns} ? {
cuesheetMode={cuesheetMode} cuesheetMode,
setCuesheetMode={setCuesheetMode} setCuesheetMode,
handleResetResizing={resetColumnResizing} isCurrentRundown,
handleResetReordering={resetColumnOrder} }
handleClearToggles={setAllVisible} : undefined
/> }
)} showShare={tableRoot === 'cuesheet'}
/>
<TableVirtuoso <TableVirtuoso
ref={virtuosoRef} ref={virtuosoRef}
data={data} data={flatRundown}
context={virtuosoContext} context={virtuosoContext}
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined} style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
computeItemKey={computeItemKey} computeItemKey={computeItemKey}
@@ -264,10 +276,13 @@ interface CuesheetVirtuosoContext {
listeners: ReturnType<typeof useTableNav>['listeners']; listeners: ReturnType<typeof useTableNav>['listeners'];
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows']; rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
table: Table<ExtendedEntry>; table: Table<ExtendedEntry>;
handleAddNew?: (type: SupportedEntry) => void;
} }
const EmptyPlaceholder = memo(function EmptyPlaceholder() { const EmptyPlaceholder = memo(function EmptyPlaceholder({
return <EmptyTableBody text='No data in rundown' />; context,
}: TableProps & ContextProp<CuesheetVirtuosoContext>) {
return <EmptyTableBody handleAddNew={context.handleAddNew} />;
}); });
const CuesheetTableElement = memo(function CuesheetTableElement({ const CuesheetTableElement = memo(function CuesheetTableElement({
@@ -49,6 +49,10 @@ function DurationInput({
setIsEditing(false); setIsEditing(false);
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
}; };
const handleTabOut = useCallback(() => {
setIsEditing(false);
// Tab moves focus naturally; do not steal it back to parent
}, []);
const handleUpdate = useCallback( const handleUpdate = useCallback(
(newValue: string) => { (newValue: string) => {
@@ -95,8 +99,10 @@ function DurationInput({
ref={inputRef} ref={inputRef}
initialValue={timeString} initialValue={timeString}
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
submitOnTab={false}
handleUpdate={handleUpdate} handleUpdate={handleUpdate}
handleCancelUpdate={handleFakeBlur} handleCancelUpdate={handleFakeBlur}
handleTabCancel={handleTabOut}
/> />
) : ( ) : (
<TextLikeInput <TextLikeInput
@@ -30,15 +30,16 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
} }
}; };
if (!initialValue && readOnly) {
return null;
}
if (!initialValue) { if (!initialValue) {
return ( return (
<Input <Input
variant='ghosted' variant='ghosted'
className={style.imageInput} className={style.imageInput}
fluid fluid
readOnly={readOnly}
// we disable the field to prevent receiving focus
disabled={readOnly}
placeholder='Paste image URL' placeholder='Paste image URL'
onBlur={(event) => handleUpdate(event.currentTarget.value)} onBlur={(event) => handleUpdate(event.currentTarget.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -8,13 +8,24 @@ interface SingleLineCellProps {
fieldId?: string; fieldId?: string;
fieldLabel?: string; fieldLabel?: string;
allowSubmitSameValue?: boolean; allowSubmitSameValue?: boolean;
submitOnTab?: boolean;
handleUpdate: (newValue: string) => void; handleUpdate: (newValue: string) => void;
handleCancelUpdate?: () => void; handleCancelUpdate?: () => void;
handleTabCancel?: () => void;
} }
const SingleLineCell = forwardRef( const SingleLineCell = forwardRef(
( (
{ initialValue, fieldId, fieldLabel, allowSubmitSameValue, handleUpdate, handleCancelUpdate }: SingleLineCellProps, {
initialValue,
fieldId,
fieldLabel,
allowSubmitSameValue,
submitOnTab,
handleUpdate,
handleCancelUpdate,
handleTabCancel,
}: SingleLineCellProps,
inputRef, inputRef,
) => { ) => {
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
@@ -25,7 +36,9 @@ const SingleLineCell = forwardRef(
allowKeyboardNavigation: true, allowKeyboardNavigation: true,
submitOnEnter: true, // single line should submit on enter submitOnEnter: true, // single line should submit on enter
submitOnCtrlEnter: true, submitOnCtrlEnter: true,
submitOnTab,
onCancelUpdate: handleCancelUpdate, onCancelUpdate: handleCancelUpdate,
onTabCancel: handleTabCancel,
}); });
// expose a subset of the methods to the parent // expose a subset of the methods to the parent
@@ -50,6 +50,10 @@ function TimeInputDuration({
setIsEditing(false); setIsEditing(false);
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
}; };
const handleTabOut = useCallback(() => {
setIsEditing(false);
// Tab moves focus naturally; do not steal it back to parent
}, []);
const handleUpdate = useCallback( const handleUpdate = useCallback(
(newValue: string) => { (newValue: string) => {
@@ -96,8 +100,10 @@ function TimeInputDuration({
ref={inputRef} ref={inputRef}
initialValue={timeString} initialValue={timeString}
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
submitOnTab={false}
handleUpdate={handleUpdate} handleUpdate={handleUpdate}
handleCancelUpdate={handleFakeBlur} handleCancelUpdate={handleFakeBlur}
handleTabCancel={handleTabOut}
/> />
) : ( ) : (
<TextLikeInput <TextLikeInput
@@ -11,78 +11,92 @@ import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover'; import PopoverContents from '../../../../common/components/popover/Popover';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { CuesheetOptions, usePersistedCuesheetOptions } from '../../cuesheet.options';
import { useCuesheetPermissions } from '../../useTablePermissions'; import { useCuesheetPermissions } from '../../useTablePermissions';
import CuesheetShareModal from './CuesheetShareModal'; import CuesheetShareModal from './CuesheetShareModal';
import style from './CuesheetTableSettings.module.scss'; import style from './CuesheetTableSettings.module.scss';
interface CuesheetTableSettingsProps { type TableHeaderOptionsStore = {
columns: Column<ExtendedEntry, unknown>[]; hideTableSeconds: boolean;
hideIndexColumn: boolean;
showDelayedTimes: boolean;
hideDelays: boolean;
setOption: <K extends keyof TableHeaderOptionValues>(key: K, value: TableHeaderOptionValues[K]) => void;
};
type TableHeaderOptionValues = Pick<
TableHeaderOptionsStore,
'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays'
>;
type TableModeControls = {
cuesheetMode: AppMode; cuesheetMode: AppMode;
setCuesheetMode: (mode: AppMode) => void; setCuesheetMode: (mode: AppMode) => void;
handleResetResizing: () => void; isCurrentRundown?: boolean;
handleResetReordering: () => void; };
handleClearToggles: () => void;
}
export interface ViewSettingsProps { interface CuesheetTableHeaderToolbarProps {
optionsStore: CuesheetOptions;
}
export interface ColumnSettingsProps {
columns: Column<ExtendedEntry, unknown>[]; columns: Column<ExtendedEntry, unknown>[];
optionsStore: TableHeaderOptionsStore;
handleResetResizing: () => void; handleResetResizing: () => void;
handleResetReordering: () => void; handleResetReordering: () => void;
handleClearToggles: () => void; handleClearToggles: () => void;
insertElement?: ReactNode;
modeControls?: TableModeControls;
showShare?: boolean;
} }
export default function CuesheetTableSettings({ export default function CuesheetTableHeaderToolbar({
columns, columns,
cuesheetMode, optionsStore,
setCuesheetMode,
handleResetResizing, handleResetResizing,
handleResetReordering, handleResetReordering,
handleClearToggles, handleClearToggles,
}: CuesheetTableSettingsProps) { insertElement,
modeControls,
showShare = false,
}: CuesheetTableHeaderToolbarProps) {
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode); const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
const canShare = useCuesheetPermissions((state) => state.canShare); const canShare = useCuesheetPermissions((state) => state.canShare);
const options = usePersistedCuesheetOptions();
const toggleCuesheetMode = (mode: AppMode[]) => { const toggleCuesheetMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode
const newValue = mode.at(0); const newValue = mode.at(0);
if (!newValue) return; if (!newValue || !modeControls) return;
setCuesheetMode(newValue); modeControls.setCuesheetMode(newValue);
}; };
const isBackground = !(modeControls?.isCurrentRundown ?? true);
return ( return (
<Toolbar.Root className={style.tableSettings}> <Toolbar.Root className={style.tableSettings} data-background-rundown={isBackground}>
<ViewSettings optionsStore={options} /> <ViewSettings optionsStore={optionsStore} />
<ColumnSettings <ColumnSettings
columns={columns} columns={columns}
handleResetResizing={handleResetResizing} handleResetResizing={handleResetResizing}
handleResetReordering={handleResetReordering} handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles} handleClearToggles={handleClearToggles}
/> />
{canChangeMode && ( {modeControls && canChangeMode && (
<ToggleGroup <div className={style.apart}>
value={[cuesheetMode]} {insertElement}
onValueChange={toggleCuesheetMode} <ToggleGroup
className={cx([style.group, style.apart])} value={[modeControls.cuesheetMode]}
> onValueChange={toggleCuesheetMode}
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}> className={style.group}
Run disabled={!modeControls.isCurrentRundown}
</Toolbar.Button> >
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}> <Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Edit Run
</Toolbar.Button> </Toolbar.Button>
</ToggleGroup> <Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit
</Toolbar.Button>
</ToggleGroup>
</div>
)} )}
{canShare && ( {showShare && canShare && (
<> <>
<Editor.Separator orientation='vertical' /> <Editor.Separator orientation='vertical' />
<CuesheetShareModal /> <CuesheetShareModal />
@@ -92,9 +106,18 @@ export default function CuesheetTableSettings({
); );
} }
export function ViewSettings({ optionsStore }: ViewSettingsProps) { interface ViewSettingsProps {
const options = optionsStore; optionsStore: TableHeaderOptionsStore;
}
interface ColumnSettingsProps {
columns: Column<ExtendedEntry, unknown>[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
}
function ViewSettings({ optionsStore }: ViewSettingsProps) {
return ( return (
<Popover.Root> <Popover.Root>
<Popover.Trigger <Popover.Trigger
@@ -115,15 +138,15 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
<Editor.Label className={style.sectionTitle}>Element visibility</Editor.Label> <Editor.Label className={style.sectionTitle}>Element visibility</Editor.Label>
<Editor.Label className={style.option}> <Editor.Label className={style.option}>
<Checkbox <Checkbox
defaultChecked={options.hideTableSeconds} defaultChecked={optionsStore.hideTableSeconds}
onCheckedChange={(checked) => options.setOption('hideTableSeconds', checked)} onCheckedChange={(checked) => optionsStore.setOption('hideTableSeconds', checked)}
/> />
Hide seconds in table Hide seconds in table
</Editor.Label> </Editor.Label>
<Editor.Label className={style.option}> <Editor.Label className={style.option}>
<Checkbox <Checkbox
defaultChecked={options.hideIndexColumn} defaultChecked={optionsStore.hideIndexColumn}
onCheckedChange={(checked) => options.setOption('hideIndexColumn', checked)} onCheckedChange={(checked) => optionsStore.setOption('hideIndexColumn', checked)}
/> />
Hide index column Hide index column
</Editor.Label> </Editor.Label>
@@ -133,15 +156,15 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
<Editor.Label className={style.sectionTitle}>Table Behaviour</Editor.Label> <Editor.Label className={style.sectionTitle}>Table Behaviour</Editor.Label>
<Editor.Label className={style.option}> <Editor.Label className={style.option}>
<Checkbox <Checkbox
defaultChecked={options.showDelayedTimes} defaultChecked={optionsStore.showDelayedTimes}
onCheckedChange={(checked) => options.setOption('showDelayedTimes', checked)} onCheckedChange={(checked) => optionsStore.setOption('showDelayedTimes', checked)}
/> />
Show delayed times Show delayed times
</Editor.Label> </Editor.Label>
<Editor.Label className={style.option}> <Editor.Label className={style.option}>
<Checkbox <Checkbox
defaultChecked={options.hideDelays} defaultChecked={optionsStore.hideDelays}
onCheckedChange={(checked) => options.setOption('hideDelays', checked)} onCheckedChange={(checked) => optionsStore.setOption('hideDelays', checked)}
/> />
Hide delay entries Hide delay entries
</Editor.Label> </Editor.Label>
@@ -151,7 +174,7 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
); );
} }
export function ColumnSettings({ function ColumnSettings({
columns, columns,
handleResetResizing, handleResetResizing,
handleResetReordering, handleResetReordering,
@@ -177,6 +200,7 @@ export function ColumnSettings({
{columns.map((column) => { {columns.map((column) => {
const columnHeader = column.columnDef.header; const columnHeader = column.columnDef.header;
const visible = column.getIsVisible(); const visible = column.getIsVisible();
return ( return (
<Editor.Label key={`${column.id}-${visible}`} className={style.option}> <Editor.Label key={`${column.id}-${visible}`} className={style.option}>
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} /> <Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} />
@@ -8,6 +8,24 @@
gap: 1rem; gap: 1rem;
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
border-radius: 3px 3px 0 0; border-radius: 3px 3px 0 0;
transition: background-color 0.2s ease-in-out;
&[data-background-rundown='true'] {
background-color: rgba($ontime-color, 0.5);
.apart::before {
content: 'BACKGROUND EDIT';
color: $ui-white;
animation: blinker 2s ease-in-out infinite;
}
}
}
@keyframes blinker {
50% {
color: transparent;
}
} }
.sectionTitle { .sectionTitle {
@@ -43,6 +61,9 @@
.apart { .apart {
margin-left: auto; margin-left: auto;
display: flex;
align-items: center;
gap: 1rem;
} }
/** styles should match RundownHeader.module.scss */ /** styles should match RundownHeader.module.scss */
@@ -70,12 +91,17 @@
font-size: calc(1rem - 2px); font-size: calc(1rem - 2px);
font-weight: 600; font-weight: 600;
&[data-disabled] {
cursor: not-allowed;
opacity: 0.4;
}
&:focus-visible { &:focus-visible {
background: transparent; background: transparent;
outline: 1px solid $blue-500; outline: 1px solid $blue-500;
} }
&:hover:not(:disabled):not(:active) { &:hover:not([data-disabled]):not(:active) {
background: $gray-1000; background: $gray-1000;
} }
@@ -87,7 +113,7 @@
background: $blue-700; background: $blue-700;
color: $ui-white; color: $ui-white;
&:hover:not(:disabled):not(:active) { &:hover:not([data-disabled]):not(:active) {
background: $blue-600; background: $blue-600;
} }
} }
@@ -1,23 +0,0 @@
import { CustomFields, MaybeNumber, OntimeEntryCommonKeys } from 'ontime-types';
import { millisToString } from 'ontime-utils';
type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
/**
* @description parses a field for export
* @param {string} field
* @param {*} data
* @return {string}
*/
export const parseField = (field: CsvHeaderKey, data: unknown): string => {
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
return millisToString(data as MaybeNumber, { fallback: '' });
}
if (field === 'skip') {
return data ? 'x' : '';
}
return String(data ?? '');
};
@@ -5,12 +5,33 @@ import { useCallback, useEffect, useMemo } from 'react';
import { sessionScope } from '../../externals'; import { sessionScope } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig'; import { AppMode, sessionKeys } from '../../ontimeConfig';
import { getCuesheetPermissionsPolicy } from './cuesheet.policies'; import { getCuesheetPermissionsPolicy } from './cuesheet.policies';
import type { CuesheetPermissions } from './useTablePermissions';
import { useCuesheetPermissions } from './useTablePermissions'; import { useCuesheetPermissions } from './useTablePermissions';
type CuesheetPolicyOptions = {
// Run mode is only meaningful for the loaded rundown — background rundowns force Edit.
canRunMode: boolean;
};
export function getEffectiveCuesheetMode(
permissions: Pick<CuesheetPermissions, 'canChangeMode'>,
storedMode: AppMode,
{ canRunMode }: CuesheetPolicyOptions,
) {
if (!permissions.canChangeMode) {
return AppMode.Run;
}
return canRunMode ? storedMode : AppMode.Edit;
}
/** /**
* Applies cuesheet permissions to shared state and exposes the effective mode for the UI. * Applies cuesheet permissions to shared state and exposes the effective mode for the UI.
*/ */
export function useApplyCuesheetPolicy(preset: URLPreset | undefined): { export function useApplyCuesheetPolicy(
preset: URLPreset | undefined,
{ canRunMode }: CuesheetPolicyOptions,
): {
cuesheetMode: AppMode; cuesheetMode: AppMode;
setCuesheetMode: (mode: AppMode) => void; setCuesheetMode: (mode: AppMode) => void;
} { } {
@@ -26,16 +47,14 @@ export function useApplyCuesheetPolicy(preset: URLPreset | undefined): {
defaultValue: preset ? AppMode.Run : AppMode.Edit, defaultValue: preset ? AppMode.Run : AppMode.Edit,
}); });
const cuesheetMode = permissions.canChangeMode ? storedCuesheetMode : AppMode.Run; const cuesheetMode = getEffectiveCuesheetMode(permissions, storedCuesheetMode, { canRunMode });
const setCuesheetMode = useCallback( const setCuesheetMode = useCallback(
(mode: AppMode) => { (mode: AppMode) => {
if (!permissions.canChangeMode) { if (!permissions.canChangeMode || !canRunMode) return;
return;
}
setStoredCuesheetMode(mode); setStoredCuesheetMode(mode);
}, },
[permissions.canChangeMode, setStoredCuesheetMode], [canRunMode, permissions.canChangeMode, setStoredCuesheetMode],
); );
// Keep the shared permissions store aligned with the active preset policy. // Keep the shared permissions store aligned with the active preset policy.
@@ -0,0 +1,65 @@
import { useSessionStorage } from '@mantine/hooks';
import { startTransition, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
import { serverURL } from '../../externals';
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
}
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
if (storedSelectedRundownId && availableRundownIds.has(storedSelectedRundownId)) return storedSelectedRundownId;
return FOLLOW_LOADED_RUNDOWN_ID;
}
export function useCuesheetRundownSelection() {
'use memo';
const { data: projectRundowns } = useProjectRundowns();
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
key: storageKey,
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
});
const availableRundownIds = new Set(projectRundowns.rundowns.map(({ id }) => id)).add(FOLLOW_LOADED_RUNDOWN_ID);
const { loaded: loadedRundownId } = projectRundowns;
const selectedRundownId = resolveSelectedRundownId(storedSelectedRundownId, availableRundownIds);
return {
loadedRundownId,
selectedRundownId,
projectRundowns: projectRundowns.rundowns,
setSelectedRundownId: (rundownId: string) => {
startTransition(() => {
setStoredSelectedRundownId(rundownId);
});
},
};
}
export function useDirectLinkToBackgroundEdit() {
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const navigate = useNavigate();
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
return useCallback(
async (rundownId: string) => {
await navigate('/cuesheet');
startTransition(() => setStoredSelectedRundownId(rundownId));
},
[setStoredSelectedRundownId, navigate],
);
}
@@ -1,6 +1,8 @@
@use '@/theme/viewerDefs' as *; @use '@/theme/viewerDefs' as *;
$timeline-height: 1rem; $timeline-height: 1rem;
$group-band-height: 0.5rem;
$group-band-gap: 0.25rem;
$timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color)); $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color));
.timelineContainer { .timelineContainer {
@@ -17,7 +19,21 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
position: relative; position: relative;
font-weight: 600; font-weight: 600;
height: 100%; height: 100%;
box-shadow: inset 0 1rem 0 0 var(--card-background-color-override, $viewer-card-bg-color); // reserve the top region (progress bar + gap + group band) with the card background
box-shadow: inset 0 calc(#{$timeline-height} + #{$group-band-gap} + #{$group-band-height}) 0 0
var(--card-background-color-override, $viewer-card-bg-color);
}
.groupBand {
height: $group-band-height;
margin-top: $group-band-gap;
width: 100%;
background: var(--group-colour, transparent);
display: flex;
align-items: center;
position: relative;
z-index: 2;
overflow: visible;
} }
.column { .column {
@@ -92,7 +108,6 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
border-top: 2px solid var(--background-color-override, $viewer-background-color); border-top: 2px solid var(--background-color-override, $viewer-background-color);
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300); box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
text-transform: capitalize;
white-space: normal; white-space: normal;
&[data-status='done'] { &[data-status='done'] {
@@ -119,6 +134,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
.status { .status {
width: fit-content; width: fit-content;
text-transform: uppercase;
} }
.status.due { .status.due {
+1 -4
View File
@@ -55,10 +55,6 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, !fixedSize); return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, !fixedSize);
}, [rundown, scheduleStart, scheduleEnd, screenWidth, fixedSize]); }, [rundown, scheduleStart, scheduleEnd, screenWidth, fixedSize]);
if (totalDuration === 0) {
return null;
}
// Pre-calculate event statuses // Pre-calculate event statuses
let currentStatus: ProgressStatus = selectedEventId ? 'done' : 'future'; let currentStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
const statusMap: Record<string, ProgressStatus> = {}; const statusMap: Record<string, ProgressStatus> = {};
@@ -105,6 +101,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
title={displayTitle} title={displayTitle}
cue={event.cue} cue={event.cue}
width={position.width} width={position.width}
groupColour={event.groupColour}
/> />
); );
})} })}
@@ -26,6 +26,7 @@ interface TimelineEntryProps {
title: string; title: string;
width: number; width: number;
cue: string; cue: string;
groupColour?: string;
ref?: RefObject<HTMLDivElement | null>; ref?: RefObject<HTMLDivElement | null>;
} }
@@ -48,6 +49,7 @@ export function TimelineEntry({
title, title,
width, width,
cue, cue,
groupColour,
ref, ref,
}: TimelineEntryProps) { }: TimelineEntryProps) {
const formattedStartTime = formatTime(start, formatOptions); const formattedStartTime = formatTime(start, formatOptions);
@@ -67,6 +69,7 @@ export function TimelineEntry({
{ {
'--color': colour, '--color': colour,
'--lighter': lighterColour ?? '', '--lighter': lighterColour ?? '',
'--group-colour': groupColour ?? 'transparent',
left: `${left}px`, left: `${left}px`,
width: `${width}px`, width: `${width}px`,
} as CSSProperties } as CSSProperties
@@ -74,6 +77,7 @@ export function TimelineEntry({
data-testid={cue} data-testid={cue}
> >
{status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />} {status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
<div className={style.groupBand} />
<div <div
className={cx([style.content, width < 20 && style.hide, !hasLink && style.separeLeft])} className={cx([style.content, width < 20 && style.hide, !hasLink && style.separeLeft])}
data-status={status} data-status={status}
@@ -153,6 +157,8 @@ function TimelineEntryStatus({
statusText = getLocalizedString('timeline.live'); statusText = getLocalizedString('timeline.live');
} else if (statusText === 'pending') { } else if (statusText === 'pending') {
statusText = getLocalizedString('timeline.due'); statusText = getLocalizedString('timeline.due');
} else if (statusText === 'done') {
statusText = getLocalizedString('timeline.done');
} }
const isDue = status === 'future' && timeToStart <= 0; const isDue = status === 'future' && timeToStart <= 0;
@@ -38,6 +38,8 @@ export default function TimelinePageLoader() {
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) { function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const { mainSource, timeformat } = useTimelineOptions(); const { mainSource, timeformat } = useTimelineOptions();
const { getLocalizedString } = useTranslation();
// holds copy of the rundown with only relevant events // holds copy of the rundown with only relevant events
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId); const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId);
@@ -50,6 +52,8 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
const defaultFormat = getDefaultFormat(settings?.timeFormat); const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = useMemo(() => getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]); const progressOptions = useMemo(() => getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]);
const hasContent = totalDuration > 0;
return ( return (
<div className='timeline' data-testid='timeline-view'> <div className='timeline' data-testid='timeline-view'>
<ViewParamsEditor target={OntimeView.Timeline} viewOptions={progressOptions} /> <ViewParamsEditor target={OntimeView.Timeline} viewOptions={progressOptions} />
@@ -61,12 +65,16 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
<TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} /> <TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} />
<Timeline {hasContent ? (
firstStart={firstStart} <Timeline
rundown={scopedRundown} firstStart={firstStart}
selectedEventId={selectedEventId} rundown={scopedRundown}
totalDuration={totalDuration} selectedEventId={selectedEventId}
/> totalDuration={totalDuration}
/>
) : (
<EmptyPage text={getLocalizedString('common.no_data')} />
)}
</div> </div>
); );
} }
@@ -20,7 +20,7 @@ export default function TimelineSections({ now, next, followedBy, mainSource }:
const state = useExpectedStartData(); const state = useExpectedStartData();
// gather card data // gather card data
const titleNow = getPropertyValue(now, mainSource ?? 'title') ?? '-'; const titleNow = now ? (getPropertyValue(now, mainSource ?? 'title') ?? '-') : getLocalizedString('timeline.standby');
const dueText = getLocalizedString('timeline.due').toUpperCase(); const dueText = getLocalizedString('timeline.due').toUpperCase();
const nextText = getPropertyValue(next, mainSource ?? 'title') ?? '-'; const nextText = getPropertyValue(next, mainSource ?? 'title') ?? '-';
const followedByText = getPropertyValue(followedBy, mainSource ?? 'title') ?? '-'; const followedByText = getPropertyValue(followedBy, mainSource ?? 'title') ?? '-';
@@ -96,63 +96,77 @@ export function useScopedRundown(
): ScopedRundownData { ): ScopedRundownData {
const { hidePast } = useTimelineOptions(); const { hidePast } = useTimelineOptions();
const data = useMemo(() => { const data = useMemo(
if (rundown.length === 0) { () => computeScopedRundown(rundown, selectedEventId, hidePast),
return { scopedRundown: [], firstStart: 0, totalDuration: 0 }; [hidePast, rundown, selectedEventId],
} );
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null;
let totalDuration = 0;
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
for (let i = 0; i < rundown.length; i++) {
const currentEntry = rundown[i];
// we only deal with playableEvents
if (isOntimeEvent(currentEntry) && isPlayableEvent(currentEntry)) {
if (currentEntry.id === selectedEventId) {
selectedIndex = i;
}
// maybe filter past
if (hidePast && i < selectedIndex) {
continue;
}
// add to scopedRundown
scopedRundown.push(currentEntry);
/**
* Derive timers
* This logic is partially from rundownCache.generate
* With the addition of deriving the current day offset
*/
if (firstStart === null) {
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
}
}
}
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
}, [hidePast, rundown, selectedEventId]);
return data; return data;
} }
/**
* Pure computation behind useScopedRundown: filters to playable events and derives timers
*/
export function computeScopedRundown(
rundown: ExtendedEntry<OntimeEntry>[],
selectedEventId: MaybeString,
hidePast: boolean,
): ScopedRundownData {
if (rundown.length === 0) {
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
}
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart: number | null = null;
let totalDuration = 0;
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
for (let i = 0; i < rundown.length; i++) {
// we only deal with playableEvents
const currentEntry = rundown[i];
if (!isOntimeEvent(currentEntry) || !isPlayableEvent(currentEntry)) {
continue;
}
if (currentEntry.id === selectedEventId) {
selectedIndex = i;
}
// maybe filter past
if (hidePast && i < selectedIndex) {
continue;
}
// add to scopedRundown
scopedRundown.push(currentEntry);
/**
* Derive timers
* This logic is partially from rundownCache.generate
* With the addition of deriving the current day offset
*/
if (firstStart === null) {
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
}
}
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
}
type UpcomingEvents = { type UpcomingEvents = {
now: ExtendedEntry<OntimeEvent> | null; now: ExtendedEntry<OntimeEvent> | null;
next: ExtendedEntry<OntimeEvent> | null; next: ExtendedEntry<OntimeEvent> | null;
+5
View File
@@ -71,6 +71,11 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
configure: logProxyRequests, configure: logProxyRequests,
}, },
'^/mcp': {
target: 'http://localhost:4001/',
changeOrigin: true,
configure: logProxyRequests,
},
'^/ws*': { '^/ws*': {
target: 'http://localhost:4001/', target: 'http://localhost:4001/',
changeOrigin: true, changeOrigin: true,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-electron", "name": "ontime-electron",
"version": "4.8.0", "version": "4.10.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",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/resolver", "name": "@getontime/resolver",
"version": "4.8.0", "version": "4.10.0",
"type": "module", "type": "module",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts", "types": "./dist/main.d.ts",
@@ -20,7 +20,7 @@
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"devDependencies": { "devDependencies": {
"ontime-types": "workspace:^4.2.1", "ontime-types": "workspace:^4.2.1",
"tsdown": "^0.21.5", "tsdown": "^0.22.4",
"typescript": "catalog:" "typescript": "catalog:"
}, },
"files": [ "files": [
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"ignoreDeprecations": "6.0" "isolatedDeclarations": true,
"allowJs": false
} }
} }
+9 -1
View File
@@ -1,5 +1,13 @@
{ {
"$schema": "../../node_modules/oxlint/configuration_schema.json", "$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.json"], "extends": ["../../.oxlintrc.json"],
"plugins": ["unicorn", "typescript", "oxc", "vitest", "node", "promise"] "plugins": ["unicorn", "typescript", "oxc", "vitest", "node", "promise"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
}
]
}
} }
+12 -10
View File
@@ -2,31 +2,33 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "4.8.0", "version": "4.10.0",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
"@modelcontextprotocol/sdk": "^1.15.0",
"cookie": "1.0.2", "cookie": "1.0.2",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"cors": "2.8.5", "cors": "2.8.6",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"express": "5.1.0", "express": "5.2.1",
"express-static-gzip": "3.0.0", "express-static-gzip": "3.0.1",
"express-validator": "7.2.1", "express-validator": "7.3.2",
"fast-equals": "^5.0.1", "fast-equals": "^6.0.0",
"google-auth-library": "^9.4.2", "google-auth-library": "^9.4.2",
"lowdb": "^7.0.1", "lowdb": "^7.0.1",
"multer": "2.1.0", "multer": "2.2.0",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"osc-min": "2.1.2", "osc-min": "2.1.2",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"ws": "^8.18.0", "ws": "^8.18.0",
"xlsx": "^0.18.5" "xlsx": "^0.18.5",
"zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@types/cookie-parser": "1.4.9", "@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19", "@types/cors": "2.8.19",
"@types/express": "5.0.3", "@types/express": "5.0.6",
"@types/multer": "2.1.0", "@types/multer": "2.1.0",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
@@ -110,14 +110,13 @@ type old_URLPreset = {
/** /**
* migrates a url presets from v3 to v4 * migrates a url presets from v3 to v4
* - pathAndParams split into a target and search * - pathAndParams split into a target and search
*
*/ */
export function migrateURLPresets(jsonData: object): URLPreset[] | undefined { export function migrateURLPresets(jsonData: object): URLPreset[] | undefined {
if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) { if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) {
const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset; const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset;
const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => { const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => {
const [target, search] = pathAndParams.split('?'); const [target, search] = pathAndParams.split('?');
return { enabled, alias, target, search, options: {} } as URLPreset; return { enabled, alias, target, search, displayInNav: false, options: {} } as URLPreset;
}); });
return newURLPreset; return newURLPreset;
} }
@@ -208,6 +208,7 @@ describe('v3 to v4', () => {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', 'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
options: {}, options: {},
}, },
{ {
@@ -216,6 +217,7 @@ describe('v3 to v4', () => {
target: OntimeView.Timer, target: OntimeView.Timer,
search: search:
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', 'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
displayInNav: false,
options: {}, options: {},
}, },
]; ];
@@ -1,11 +1,13 @@
import { import {
CustomField, CustomField,
EntryId,
OntimeDelay, OntimeDelay,
OntimeEvent, OntimeEvent,
OntimeGroup, OntimeGroup,
OntimeMilestone, OntimeMilestone,
Rundown, Rundown,
SupportedEntry, SupportedEntry,
isOntimeGroup,
} from 'ontime-types'; } from 'ontime-types';
import { makeNewRundown } from '../../../models/dataModel.js'; import { makeNewRundown } from '../../../models/dataModel.js';
@@ -59,13 +61,24 @@ export function makeOntimeMilestone(patch: Partial<OntimeMilestone>): OntimeMile
} }
/** /**
* Utility to create a rundown object * Utility to create a rundown object.
* Derives flatOrder from order/entries when the patch does not provide one,
* so test fixtures match the shape of a processed rundown.
*/ */
export function makeRundown(patch: Partial<Rundown>): Rundown { export function makeRundown(patch: Partial<Rundown>): Rundown {
return { const rundown = { ...makeNewRundown(), ...patch };
...makeNewRundown(), if (!patch.flatOrder) {
...patch, const flatOrder: EntryId[] = [];
}; for (const id of rundown.order) {
flatOrder.push(id);
const entry = rundown.entries[id];
if (isOntimeGroup(entry)) {
flatOrder.push(...entry.entries);
}
}
rundown.flatOrder = flatOrder;
}
return rundown;
} }
export function makeCustomField(patch: Partial<CustomField>): CustomField { export function makeCustomField(patch: Partial<CustomField>): CustomField {
@@ -63,7 +63,7 @@ describe('createTransaction', () => {
expect(typeof commit).toBe('function'); expect(typeof commit).toBe('function');
}); });
it('should return the updated data after commit is called and writes are scheduled', () => { it('should return the updated data after commit is called and writes are scheduled', async () => {
const { rundown, customFields, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true }); const { rundown, customFields, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true });
rundown.title = 'Another Title'; rundown.title = 'Another Title';
customFields['newField'] = { customFields['newField'] = {
@@ -72,8 +72,7 @@ describe('createTransaction', () => {
colour: 'blue', colour: 'blue',
}; };
const updated = commit(); const updated = await commit();
vi.runAllTimers();
expect(updated.rundown.title).toBe('Another Title'); expect(updated.rundown.title).toBe('Another Title');
expect(updated.customFields).toHaveProperty('newField'); expect(updated.customFields).toHaveProperty('newField');
@@ -132,6 +131,23 @@ describe('processRundown()', () => {
expect(initResult.entries['3'].type).toBe(SupportedEntry.Delay); expect(initResult.entries['3'].type).toBe(SupportedEntry.Delay);
}); });
it('produces order, flatOrder and applies delays to following events', () => {
const rundown = makeRundown({
order: ['1', 'delay', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 1000, duration: 1000 }),
delay: makeOntimeDelay({ id: 'delay', duration: 250 }),
'2': makeOntimeEvent({ id: '2', timeStart: 1000, timeEnd: 2000, duration: 1000 }),
},
});
const processed = processRundown(rundown, {});
expect(processed.order).toEqual(['1', 'delay', '2']);
expect(processed.flatEntryOrder).toEqual(['1', 'delay', '2']);
expect((processed.entries['2'] as OntimeEvent).delay).toBe(250);
});
it('calculates delays versions of a given rundown', () => { it('calculates delays versions of a given rundown', () => {
const rundown = makeRundown({ const rundown = makeRundown({
order: ['1', '2'], order: ['1', '2'],
@@ -1622,6 +1638,34 @@ describe('rundownMutation.applyDelay()', () => {
}, },
}); });
}); });
it('uses the passed rundown own order, not the loaded rundown metadata', () => {
// The loaded rundown has completely different events
const loadedRundown = makeRundown({
order: ['loaded-delay', 'loaded-ev'],
entries: {
'loaded-delay': makeOntimeDelay({ id: 'loaded-delay', duration: 9999 }),
'loaded-ev': makeOntimeEvent({ id: 'loaded-ev', timeStart: 0, timeEnd: 100, duration: 100 }),
},
});
rundownCache.init(loadedRundown, {});
// Background rundown is independent of the loaded one
const bgRundown = makeRundown({
order: ['bg-delay', 'bg-ev'],
entries: {
'bg-delay': makeOntimeDelay({ id: 'bg-delay', duration: 500 }),
'bg-ev': makeOntimeEvent({ id: 'bg-ev', timeStart: 0, timeEnd: 200, duration: 200 }),
},
});
rundownMutation.applyDelay(bgRundown, bgRundown.entries['bg-delay'] as OntimeDelay);
// bg-ev must be shifted by its own delay (500), not influenced by the loaded rundown
expect((bgRundown.entries['bg-ev'] as OntimeEvent).timeStart).toBe(500);
// loaded-ev must be untouched
expect((loadedRundown.entries['loaded-ev'] as OntimeEvent).timeStart).toBe(0);
});
}); });
describe('rundownMutation.swap()', () => { describe('rundownMutation.swap()', () => {
@@ -2,13 +2,11 @@ import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types';
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils'; import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
import { assertType } from 'vitest'; import { assertType } from 'vitest';
import { demoDb } from '../../../models/demoProject.js';
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js'; import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
import { import {
calculateDayOffset, calculateDayOffset,
deleteById, deleteById,
doesInvalidateMetadata, doesInvalidateMetadata,
duplicateRundown,
getIntegerAndFraction, getIntegerAndFraction,
hasChanges, hasChanges,
makeDeepClone, makeDeepClone,
@@ -223,25 +221,6 @@ describe('calculateDayOffset()', () => {
}); });
}); });
describe('duplicateRundown', () => {
it('duplicates a given rundown', () => {
const demoRundown = demoDb.rundowns['default'];
const title = 'Duplicated Rundown';
const duplicatedRundown = duplicateRundown(demoRundown, title);
expect(duplicatedRundown).toMatchObject({
title: title,
entries: expect.any(Object),
order: expect.any(Array),
flatOrder: expect.any(Array),
});
expect(demoRundown.id).not.toEqual(duplicatedRundown.id);
expect(duplicatedRundown.order.length).toEqual(demoRundown.order.length);
expect(duplicatedRundown.flatOrder.length).toEqual(demoRundown.flatOrder.length);
expect(Object.keys(duplicatedRundown.entries).length).toEqual(Object.keys(demoRundown.entries).length);
});
});
describe('makeDeepClone()', () => { describe('makeDeepClone()', () => {
it('deep clones a group along with its nested entries', () => { it('deep clones a group along with its nested entries', () => {
const group1 = makeOntimeGroup({ id: 'group1', title: 'Group 1', entries: ['event1', 'event2'] }); const group1 = makeOntimeGroup({ id: 'group1', title: 'Group 1', entries: ['event1', 'event2'] });
+117 -87
View File
@@ -82,56 +82,85 @@ export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cac
type Transaction = { type Transaction = {
customFields: CustomFields; customFields: CustomFields;
rundown: Rundown; rundown: Rundown;
rundownMetadata: Readonly<RundownMetadata>;
commit: (shouldProcess?: boolean) => { commit: (shouldProcess?: boolean) => Promise<{
rundown: Readonly<Rundown>; rundown: Readonly<Rundown>;
rundownMetadata: Readonly<RundownMetadata>; rundownMetadata: Readonly<RundownMetadata> | null;
customFields: Readonly<CustomFields>; customFields: Readonly<CustomFields>;
revision: Readonly<number>; revision: Readonly<number>;
}; }>;
}; };
type TransactionOptions = { type TransactionOptions = {
mutableRundown?: boolean; mutableRundown?: boolean;
mutableCustomFields?: boolean; mutableCustomFields?: boolean;
/**
* Target rundown ID. Defaults to the loaded rundown.
* When targeting a non-loaded ("background") rundown, the cache and runtime
* metadata are bypassed the rundown is read from disk and persisted directly.
*/
rundownId?: string;
}; };
export function createTransaction(options: TransactionOptions): Transaction { export function createTransaction(options: TransactionOptions): Transaction {
const rundown = options.mutableRundown ? structuredClone(cachedRundown) : cachedRundown; const targetId = options.rundownId ?? cachedRundown.id;
const isLoaded = targetId === cachedRundown.id;
const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown);
const rundown = options.mutableRundown ? structuredClone(sourceRundown) : sourceRundown;
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields; const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
/** /**
* Applies a mutated rundown to the cache * Applies a mutated rundown to its persistence target
* @param shouldProcess - whether the rundown should be processed after the commit * @param shouldProcess - whether the rundown should be processed after the commit
* Some edit mutations, and custom field changes do not require processing * Some edit mutations, and custom field changes do not require processing
*/ */
function commit(shouldProcess: boolean = true) { async function commit(shouldProcess: boolean = true) {
let committedRundown: Readonly<Rundown> = isLoaded ? cachedRundown : rundown;
let committedMetadata: RundownMetadata | null = isLoaded ? rundownMetadata : null;
let committedRevision: number = sourceRundown.revision;
// if the rundown is mutable we persist the changes // if the rundown is mutable we persist the changes
if (options.mutableRundown) { if (options.mutableRundown) {
// update fields which are agnostic of whether the rundown is processed if (isLoaded) {
cachedRundown.revision = cachedRundown.revision + 1; // update fields which are agnostic of whether the rundown is processed
cachedRundown.title = rundown.title; cachedRundown.revision = cachedRundown.revision + 1;
cachedRundown.title = rundown.title;
if (!shouldProcess) { if (!shouldProcess) {
// if we dont need to process, we just reassign the commit data to the cache // if we dont need to process, we just reassign the commit data to the cache
cachedRundown.entries = rundown.entries; cachedRundown.entries = rundown.entries;
cachedRundown.order = rundown.order; cachedRundown.order = rundown.order;
cachedRundown.flatOrder = rundown.flatOrder; cachedRundown.flatOrder = rundown.flatOrder;
} else {
const processedData = processRundown(rundown, projectCustomFields, { mutate: true });
// update the cache values
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
cachedRundown.entries = entries;
cachedRundown.order = order;
cachedRundown.flatOrder = metadata.flatEntryOrder;
rundownMetadata = metadata;
}
// persist after all mutations are applied
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
committedRundown = cachedRundown;
committedMetadata = rundownMetadata;
committedRevision = cachedRundown.revision;
} else { } else {
const processedData = processRundown(rundown, projectCustomFields); // background rundown: process if requested, persist directly, no runtime metadata
// update the cache values rundown.revision = rundown.revision + 1;
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data if (shouldProcess) {
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData; const processedData = processRundown(rundown, projectCustomFields, { mutate: true });
rundown.entries = processedData.entries;
cachedRundown.entries = entries; rundown.order = processedData.order;
cachedRundown.order = order; rundown.flatOrder = processedData.flatEntryOrder;
cachedRundown.flatOrder = metadata.flatEntryOrder; }
rundownMetadata = metadata; await getDataProvider().setRundown(targetId, rundown);
committedRundown = rundown;
committedRevision = rundown.revision;
} }
// persist after all mutations are applied
getDataProvider().setRundown(cachedRundown.id, cachedRundown);
} }
// if the customFields are mutable we persist the changes // if the customFields are mutable we persist the changes
@@ -139,21 +168,20 @@ export function createTransaction(options: TransactionOptions): Transaction {
projectCustomFields = customFields; projectCustomFields = customFields;
// persist after reassignment // persist after reassignment
getDataProvider().setCustomFields(projectCustomFields); await getDataProvider().setCustomFields(projectCustomFields);
} }
return { return {
rundown: cachedRundown, rundown: committedRundown,
rundownMetadata, rundownMetadata: committedMetadata,
customFields: projectCustomFields, customFields: projectCustomFields,
revision: cachedRundown.revision, revision: committedRevision,
}; };
} }
return { return {
customFields, customFields,
rundown, rundown,
rundownMetadata,
commit, commit,
}; };
} }
@@ -288,11 +316,11 @@ function reorder(rundown: Rundown, eventFrom: OntimeEntry, eventTo: OntimeEntry,
* Mutates the given rundown * Mutates the given rundown
*/ */
function applyDelay(rundown: Rundown, delay: OntimeDelay) { function applyDelay(rundown: Rundown, delay: OntimeDelay) {
const delayIndex = rundownMetadata.flatEntryOrder.indexOf(delay.id); const delayIndex = rundown.flatOrder.indexOf(delay.id);
// if the delay is empty, or the last element // if the delay is empty, or the last element
// there is nothing do apply // there is nothing do apply
if (delay.duration === 0 || delayIndex === rundownMetadata.flatEntryOrder.length - 1) { if (delay.duration === 0 || delayIndex === rundown.flatOrder.length - 1) {
return; return;
} }
@@ -305,8 +333,8 @@ function applyDelay(rundown: Rundown, delay: OntimeDelay) {
let lastEntry: OntimeEvent | null = null; let lastEntry: OntimeEvent | null = null;
let isFirstEvent = true; let isFirstEvent = true;
for (let i = delayIndex + 1; i < rundownMetadata.flatEntryOrder.length; i++) { for (let i = delayIndex + 1; i < rundown.flatOrder.length; i++) {
const currentId = rundownMetadata.flatEntryOrder[i]; const currentId = rundown.flatOrder[i];
const currentEntry = rundown.entries[currentId]; const currentEntry = rundown.entries[currentId];
// we don't do operation on other event types // we don't do operation on other event types
@@ -588,6 +616,23 @@ export async function updateBackgroundRundown(rundownId: string, rundown: Rundow
await getDataProvider().setRundown(rundownId, rundown); await getDataProvider().setRundown(rundownId, rundown);
} }
/**
* Reads a rundown from disk and returns it in the processed shape used by clients.
* Used for non-loaded rundowns the loaded rundown is served directly from cache.
*/
export function getProcessedRundown(rundownId: string): Rundown {
const stored = getDataProvider().getRundown(rundownId);
const processed = processRundown(stored, getDataProvider().getCustomFields());
return {
id: stored.id,
title: stored.title,
entries: processed.entries,
order: processed.order,
flatOrder: processed.flatEntryOrder,
revision: stored.revision,
};
}
/** /**
* Adds a new custom field to the object and returns it * Adds a new custom field to the object and returns it
*/ */
@@ -666,7 +711,7 @@ export const customFieldMutation = {
export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) { export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) {
const rundown = structuredClone(initialRundown); const rundown = structuredClone(initialRundown);
const customFields = structuredClone(initialCustomFields); const customFields = structuredClone(initialCustomFields);
const processedData = processRundown(rundown, customFields); const processedData = processRundown(rundown, customFields, { mutate: true });
// update the cache values // update the cache values
cachedRundown.id = rundown.id; cachedRundown.id = rundown.id;
@@ -699,72 +744,57 @@ export const rundownCache = {
}; };
/** /**
* Utility updates cache after a mutation * Computes derived rundown metadata (delays, gaps, group times, ordered lists).
* Handles calculating the rundown metadata * By default the input rundown is not mutated entries are cloned as they are processed.
* Callers that own a writable rundown can pass `{ mutate: true }` to skip the per-entry clone.
* @private should not be called outside of `rundown.dao.ts`, exported for testing * @private should not be called outside of `rundown.dao.ts`, exported for testing
*/ */
export function processRundown( export function processRundown(
initialRundown: Readonly<Rundown>, initialRundown: Readonly<Rundown>,
customFields: Readonly<CustomFields>, customFields: Readonly<CustomFields>,
options?: { mutate?: boolean },
): ProcessedRundownMetadata { ): ProcessedRundownMetadata {
const { process, getMetadata } = makeRundownMetadata(customFields); const { process, getMetadata } = makeRundownMetadata(customFields, options);
for (let i = 0; i < initialRundown.order.length; i++) { for (let i = 0; i < initialRundown.order.length; i++) {
// we assign a reference to the current entry, this will be mutated in place const currentEntry = initialRundown.entries[initialRundown.order[i]];
const currentEntryId = initialRundown.order[i]; if (!currentEntry) continue;
const currentEntry = initialRundown.entries[currentEntryId];
if (!currentEntry) {
continue;
}
const { processedEntry } = process(currentEntry, null);
// if the event is a group, we process the nested entries const processedEntry = process(currentEntry, null);
// the code here is a copy of the processing of top level events if (!isOntimeGroup(processedEntry)) continue;
if (isOntimeGroup(processedEntry)) {
let groupStartTime = null;
let groupEndTime = null;
let isFirstLinked = false;
const groupEvents: EntryId[] = [];
processedEntry.duration = 0;
// check if the group contains nested entries // process nested entries and recompute aggregate group fields
for (let j = 0; j < processedEntry.entries.length; j++) { let groupStartTime: number | null = null;
const nestedEntryId = processedEntry.entries[j]; let groupEndTime: number | null = null;
const nestedEntry = initialRundown.entries[nestedEntryId]; let isFirstLinked = false;
const groupEvents: EntryId[] = [];
processedEntry.duration = 0;
if (!nestedEntry) { for (let j = 0; j < processedEntry.entries.length; j++) {
continue; const nestedEntry = initialRundown.entries[processedEntry.entries[j]];
} if (!nestedEntry) continue;
groupEvents.push(nestedEntry.id); groupEvents.push(nestedEntry.id);
const { processedEntry: processedNestedEntry } = process(nestedEntry, processedEntry.id); const processedNestedEntry = process(nestedEntry, processedEntry.id);
// we dont extract metadata of skipped events, // skip metadata aggregation for non-playable nested entries
// if this is not a playable event there is nothing else to do if (!isOntimeEvent(processedNestedEntry) || !isPlayableEvent(processedNestedEntry)) continue;
if (!isOntimeEvent(processedNestedEntry) || !isPlayableEvent(processedNestedEntry)) {
continue;
}
// first start is always the first event if (groupStartTime === null) {
if (groupStartTime === null) { groupStartTime = processedNestedEntry.timeStart;
groupStartTime = processedNestedEntry.timeStart; isFirstLinked = Boolean(processedNestedEntry.linkStart);
isFirstLinked = Boolean(processedNestedEntry.linkStart);
}
// lastEntry is the event with the latest end time
groupEndTime = processedNestedEntry.timeEnd;
if (j > 0) {
processedEntry.duration += processedNestedEntry.gap;
}
processedEntry.duration = processedEntry.duration + processedNestedEntry.duration;
} }
groupEndTime = processedNestedEntry.timeEnd;
// update group metadata if (j > 0) {
processedEntry.timeStart = groupStartTime; processedEntry.duration += processedNestedEntry.gap;
processedEntry.timeEnd = groupEndTime; }
processedEntry.isFirstLinked = isFirstLinked; processedEntry.duration += processedNestedEntry.duration;
processedEntry.entries = groupEvents;
} }
processedEntry.timeStart = groupStartTime;
processedEntry.timeEnd = groupEndTime;
processedEntry.isFirstLinked = isFirstLinked;
processedEntry.entries = groupEvents;
} }
return getMetadata(); return getMetadata();

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