mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-05 14:29:20 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d2c86d6be | |||
| 9ec12f4927 | |||
| 4c08ca2908 | |||
| 265b167600 | |||
| eb7b2a5c4b | |||
| e0ae9f7f09 | |||
| b0c778fa62 | |||
| 98d3b1f6f3 | |||
| d9dd147d4b | |||
| 9806f97d27 | |||
| b9683f00dc | |||
| 63fdc50c50 | |||
| 5acfc4a122 | |||
| a84b8b5530 | |||
| c19aa4ca28 | |||
| 805663e2ea | |||
| 0f0b32d444 | |||
| 703dee35a4 | |||
| 2a7f5b7872 |
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: code-review
|
||||
description: Review Ontime changes for concrete correctness, architecture, testing, routing, security, and maintenance issues. For GitHub Copilot review.
|
||||
---
|
||||
|
||||
# Review Ontime changes
|
||||
|
||||
Review as a maintainer. Find material defects, not speculative advice, style preferences, or generic checklists. Approve when no material issue remains.
|
||||
|
||||
## Load relevant context
|
||||
|
||||
Read the full diff, PR description, linked issue, tests, and nearby owning code. Then load only applicable guides:
|
||||
|
||||
- Every non-trivial review: [change assessment](../../../docs/agent-guides/change-assessment.md)
|
||||
- Module placement, server layers, client state, shared packages: [architecture](../../../docs/agent-guides/architecture.md)
|
||||
- Tests or changed behaviour: [testing](../../../docs/agent-guides/testing.md)
|
||||
- Comments, abstractions, naming, complexity: [code quality](../../../docs/agent-guides/code-quality.md)
|
||||
- Authentication, external input, files, integrations, assets, secrets: [security](../../../docs/agent-guides/security.md)
|
||||
- Routes, URLs, websockets, authentication, cookies, presets, assets: [routing and cloud](../../../docs/agent-guides/routing-and-cloud.md)
|
||||
- Rundowns, timers, persistence, imports, cache, realtime: [domain invariants](../../../docs/agent-guides/domain-invariants.md)
|
||||
- Commands, imports, dependencies, formatting, CI claims: [workflow](../../../docs/agent-guides/workflow.md)
|
||||
|
||||
Check a guide and nearby canonical code before citing an Ontime convention. Skip unrelated guides.
|
||||
|
||||
## Review order
|
||||
|
||||
1. Establish intent and affected runtime surfaces.
|
||||
2. Read tests first. Identify claimed behaviour and coverage.
|
||||
3. Trace implementation, errors, and state transitions.
|
||||
4. Check correctness/data integrity, security, architecture/testability, cloud routing, lifecycle/performance, maintainability.
|
||||
5. Verify claimed checks. Never claim unobserved results.
|
||||
|
||||
Passing tests do not prove architecture, routing, comments, or error paths. Review changed behaviour only; include existing problems only when the diff worsens or relies on them.
|
||||
|
||||
## Findings
|
||||
|
||||
Report only concrete, actionable issues. Each finding: tight line range, direct defect, impact and trigger, smallest viable remedy when unclear.
|
||||
|
||||
- **P0 — Critical:** data loss, exploitable vulnerability, broadly broken production. Blocks merge.
|
||||
- **P1 — High:** likely correctness failure or major supported-deployment regression. Blocks merge.
|
||||
- **P2 — Medium:** real edge-case defect, architecture regression, missing business-rule test, stale comment, meaningful maintenance risk. Normally blocks merge.
|
||||
- **P3 — Low:** local improvement with limited impact. No subjective style or tool-managed formatting.
|
||||
|
||||
Order by priority. Prefer few high-confidence findings. No praise or checklist before findings. If none, say so and note verification gaps or residual risk.
|
||||
|
||||
Then give one concise PR-level value/risk/complexity assessment. Never repeat it per finding.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Ontime agent guide
|
||||
|
||||
Make the smallest maintainable change. Keep scope narrow. Inspect nearby code first. Reuse helpers and boundaries when semantics match.
|
||||
|
||||
## Load only relevant guides
|
||||
|
||||
- Commands, validation, formatting, imports, PRs: [workflow](docs/agent-guides/workflow.md).
|
||||
- Non-trivial planning, implementation, review: [change assessment](docs/agent-guides/change-assessment.md).
|
||||
- Module placement, server layers, client state, shared packages: [architecture](docs/agent-guides/architecture.md).
|
||||
- Tests or changed behaviour: [testing](docs/agent-guides/testing.md).
|
||||
- Comments, naming, abstractions, maintainability: [code quality](docs/agent-guides/code-quality.md).
|
||||
- Authentication, external input, files, integrations, assets, secrets: [security](docs/agent-guides/security.md).
|
||||
- Navigation, URLs, API paths, websockets, redirects, cookies, static assets: [routing and cloud](docs/agent-guides/routing-and-cloud.md).
|
||||
- Rundowns, timers, imports, persistence, cache, websockets: [domain invariants](docs/agent-guides/domain-invariants.md).
|
||||
|
||||
Load multiple guides when needed. Skip unrelated guides for mechanical work.
|
||||
|
||||
## Before handoff
|
||||
|
||||
- Check the final diff for scope, stale comments, temporary code, redundant tests, generated files.
|
||||
- If work reveals a missing, stable, reusable system, domain, or product invariant, update its owning guide. Exclude guesses, one-off bugs, and implementation details.
|
||||
- Follow [workflow verification](docs/agent-guides/workflow.md). Report only observed results.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.12.0",
|
||||
"version": "4.13.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.12.0",
|
||||
"version": "4.13.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.6.0",
|
||||
"@base-ui/react": "1.7.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -13,12 +13,12 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@mantine/hooks": "^8.3.7",
|
||||
"@mantine/hooks": "^9.5.1",
|
||||
"@sentry/react": "^10.59.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-table": "^9.1.2",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.10",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.18.0",
|
||||
@@ -29,7 +29,7 @@
|
||||
"react-dom": "^19.2.7",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-hook-form": "^7.80.0",
|
||||
"react-icons": "5.6.0",
|
||||
"react-icons": "5.7.0",
|
||||
"react-router": "^8.0.1",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"zustand": "^5.0.14"
|
||||
@@ -60,7 +60,8 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "5.1.1",
|
||||
"@sentry/vite-plugin": "5.4.0",
|
||||
"@types/node": "catalog:",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
@@ -72,8 +73,8 @@
|
||||
"ontime-utils": "workspace:*",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "catalog:",
|
||||
"vite": "8.0.1",
|
||||
"vite-plugin-compression2": "2.5.1",
|
||||
"vite": "8.2.1",
|
||||
"vite-plugin-compression2": "2.5.3",
|
||||
"vite-plugin-svgr": "4.5.0",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import Button from './Button';
|
||||
|
||||
type ToggleButtonProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
|
||||
/** whether the option this button controls is currently on */
|
||||
pressed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A button which carries an on / off state.
|
||||
*
|
||||
* Keeps the pressed styling and the accessible state together, so that a toggle
|
||||
* cannot end up looking active without also announcing that it is.
|
||||
*/
|
||||
export default function ToggleButton({ pressed, ...buttonProps }: ToggleButtonProps) {
|
||||
return <Button variant={pressed ? 'primary' : 'subtle'} aria-pressed={pressed} {...buttonProps} />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.eyebrow {
|
||||
font-size: $aux-text-size;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './Eyebrow.module.scss';
|
||||
|
||||
interface EyebrowProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Eyebrow({ children, className }: PropsWithChildren<EyebrowProps>) {
|
||||
return <span className={cx([style.eyebrow, className])}>{children}</span>;
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
.separator {
|
||||
border-color: $border-color-ondark;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -23,14 +19,15 @@
|
||||
|
||||
width: 22rem;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-block: 1rem;
|
||||
|
||||
background-color: $gray-1250;
|
||||
color: $ui-white;
|
||||
border-right: 1px solid $gray-1100;
|
||||
box-shadow: $box-shadow-right;
|
||||
|
||||
&[data-open] {
|
||||
transform: translateX(0%);
|
||||
@@ -50,22 +47,51 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 3.5rem;
|
||||
padding-inline: 1.5rem;
|
||||
gap: 1rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.75rem 1rem 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
}
|
||||
|
||||
.headerText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.clientName {
|
||||
font-size: $aux-text-size;
|
||||
color: $gray-600;
|
||||
@include ellipsis-text;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-left: auto;
|
||||
color: $white-20;
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
padding: 0.5rem 1.25rem 0.25rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Dialog } from '@base-ui/react/dialog';
|
||||
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
||||
import { memo } from 'react';
|
||||
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
|
||||
import { memo, PropsWithChildren } from 'react';
|
||||
import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { LuCoffee } from 'react-icons/lu';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
@@ -14,9 +14,11 @@ import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import IconButton from '../buttons/IconButton';
|
||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||
import Eyebrow from '../eyebrow/Eyebrow';
|
||||
import ClientLink from './client-link/ClientLink';
|
||||
import EditorNavigation from './editor-navigation/EditorNavigation';
|
||||
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
|
||||
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
|
||||
import OtherAddresses from './other-addresses/OtherAddresses';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
@@ -33,7 +35,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
const isSmallScreen = useIsSmallScreen();
|
||||
|
||||
const [isRenameOpen, handlers] = useDisclosure(false);
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { fullscreen, toggle } = useFullscreenDocument();
|
||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
|
||||
const location = useLocation();
|
||||
@@ -52,75 +54,93 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
|
||||
<Dialog.Popup className={style.drawer}>
|
||||
<div className={style.header}>
|
||||
<Dialog.Title>Ontime</Dialog.Title>
|
||||
<IconButton variant='subtle-white' size='large' onClick={onClose}>
|
||||
<div className={style.headerText}>
|
||||
<Dialog.Title className={style.title}>Ontime</Dialog.Title>
|
||||
{name && <span className={style.clientName}>{name}</span>}
|
||||
</div>
|
||||
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={style.body}>
|
||||
{supportsFullscreen && (
|
||||
<NavigationMenuItem active={fullscreen} onClick={toggle}>
|
||||
Toggle Fullscreen
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
<MenuGroup label='This screen'>
|
||||
{supportsFullscreen && (
|
||||
<NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} />
|
||||
)}
|
||||
<NavigationMenuToggle
|
||||
checked={mirror}
|
||||
icon={<IoSwapVertical />}
|
||||
label='Flip Screen'
|
||||
onToggle={() => toggleMirror()}
|
||||
/>
|
||||
{canUseWakeLock && (
|
||||
<NavigationMenuToggle
|
||||
checked={keepAwake}
|
||||
icon={<LuCoffee />}
|
||||
label='Keep Awake'
|
||||
onToggle={toggleKeepAwake}
|
||||
/>
|
||||
)}
|
||||
<NavigationMenuItem onClick={handlers.open}>
|
||||
<IoPencilOutline />
|
||||
Rename Client
|
||||
</NavigationMenuItem>
|
||||
)}
|
||||
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
|
||||
Flip Screen
|
||||
<IoSwapVertical />
|
||||
{mirror && <span className={style.note}>Active</span>}
|
||||
</NavigationMenuItem>
|
||||
{canUseWakeLock && (
|
||||
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
|
||||
Keep Awake
|
||||
<LuCoffee />
|
||||
{keepAwake && <span className={style.note}>Active</span>}
|
||||
</NavigationMenuItem>
|
||||
)}
|
||||
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
|
||||
</MenuGroup>
|
||||
|
||||
<hr className={style.separator} />
|
||||
|
||||
<EditorNavigation />
|
||||
<ClientLink
|
||||
to='cuesheet'
|
||||
current={location.pathname === '/cuesheet'}
|
||||
postAction={isSmallScreen ? onClose : undefined}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
</ClientLink>
|
||||
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
|
||||
<IoLockClosedOutline />
|
||||
Operator
|
||||
</ClientLink>
|
||||
|
||||
<hr className={style.separator} />
|
||||
|
||||
{navigatorConstants.map((route) => (
|
||||
<MenuGroup label='Ontime app'>
|
||||
<EditorNavigation />
|
||||
<ClientLink
|
||||
key={route.url}
|
||||
to={route.url}
|
||||
current={location.pathname === `/${route.url}`}
|
||||
to='cuesheet'
|
||||
current={location.pathname === '/cuesheet'}
|
||||
postAction={isSmallScreen ? onClose : undefined}
|
||||
>
|
||||
{route.label}
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
</ClientLink>
|
||||
))}
|
||||
<ClientLink
|
||||
to='op'
|
||||
current={location.pathname === '/op'}
|
||||
postAction={isSmallScreen ? onClose : undefined}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Operator
|
||||
</ClientLink>
|
||||
</MenuGroup>
|
||||
|
||||
<MenuGroup label='Views'>
|
||||
{navigatorConstants.map((route) => (
|
||||
<ClientLink
|
||||
key={route.url}
|
||||
to={route.url}
|
||||
current={location.pathname === `/${route.url}`}
|
||||
postAction={isSmallScreen ? onClose : undefined}
|
||||
>
|
||||
{route.label}
|
||||
</ClientLink>
|
||||
))}
|
||||
</MenuGroup>
|
||||
|
||||
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
|
||||
</div>
|
||||
|
||||
{isLocalhost && (
|
||||
<div>
|
||||
<OtherAddresses currentLocation={location.pathname} />
|
||||
</div>
|
||||
)}
|
||||
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
|
||||
</Dialog.Popup>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
|
||||
return (
|
||||
<div className={style.group}>
|
||||
<div className={style.groupLabel}>
|
||||
<Eyebrow>{label}</Eyebrow>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
|
||||
const location = useLocation();
|
||||
const { data: urlPresets } = useUrlPresets();
|
||||
@@ -129,8 +149,7 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
|
||||
if (navPresets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<hr className={style.separator} />
|
||||
<MenuGroup label='Presets'>
|
||||
{navPresets.map((preset) => (
|
||||
<ClientLink
|
||||
key={preset.alias}
|
||||
@@ -141,6 +160,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
|
||||
{preset.alias}
|
||||
</ClientLink>
|
||||
))}
|
||||
</>
|
||||
</MenuGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||
import { memo } from 'react';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { hasCustomParams } from '../../stores/savedViewParams';
|
||||
@@ -15,21 +15,37 @@ interface ViewNavigationMenuProps {
|
||||
suppressSettings?: boolean;
|
||||
}
|
||||
|
||||
function isInteractiveKeyboardAction(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
target.closest(
|
||||
'button, a, input, textarea, select, [role="button"], [role="checkbox"], [role="switch"], [contenteditable]:not([contenteditable="false"])',
|
||||
) !== null
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ViewNavigationMenu);
|
||||
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||
const [searchParams] = useSearchParams();
|
||||
const hasSavedChanges = hasCustomParams(searchParams);
|
||||
|
||||
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
|
||||
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
|
||||
|
||||
useHotkeys([
|
||||
[
|
||||
'Space',
|
||||
() => {
|
||||
if (isNavigationLocked) return;
|
||||
menuHandler.toggle();
|
||||
(event) => {
|
||||
if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
|
||||
event.preventDefault();
|
||||
toggleMenu();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
{ preventDefault: false },
|
||||
],
|
||||
[
|
||||
'mod + ,',
|
||||
@@ -48,11 +64,11 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
|
||||
return (
|
||||
<>
|
||||
<FloatingNavigation
|
||||
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
|
||||
toggleMenu={isNavigationLocked ? undefined : toggleMenu}
|
||||
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
|
||||
hasSavedChanges={hasSavedChanges}
|
||||
/>
|
||||
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
|
||||
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+45
-12
@@ -2,35 +2,68 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
padding: 0.75rem 1.5rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
|
||||
border-left: 4px solid transparent;
|
||||
color: $action-text-color;
|
||||
border-left: 3px solid transparent;
|
||||
color: $gray-200;
|
||||
font-size: $text-body-size;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
transition-property: color;
|
||||
transition-property: color, background-color;
|
||||
transition-duration: $transition-time-action;
|
||||
|
||||
// keep icons on a fixed box so labels align regardless of the glyph
|
||||
> svg {
|
||||
flex-shrink: 0;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
color: $gray-500;
|
||||
transition: color $transition-time-action;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $ontime-color;
|
||||
background-color: $gray-1350;
|
||||
background-color: $white-3;
|
||||
color: $ui-white;
|
||||
|
||||
> svg {
|
||||
color: $gray-300;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $border-color-ondark;
|
||||
background-color: $white-7;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
outline: 2px solid $blue-500;
|
||||
background-color: $gray-1350;
|
||||
outline-offset: -2px;
|
||||
background-color: $white-3;
|
||||
}
|
||||
|
||||
// toggle rows are labels, the focusable control sits inside them
|
||||
&:focus-within {
|
||||
background-color: $white-3;
|
||||
}
|
||||
|
||||
&.current {
|
||||
background-color: $gray-1300;
|
||||
border-left: 4px solid $action-text-color;
|
||||
background-color: $bg-active;
|
||||
border-left-color: $blue-400;
|
||||
color: $ui-white;
|
||||
font-weight: 600;
|
||||
|
||||
> svg {
|
||||
color: $blue-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@include ellipsis-text;
|
||||
}
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { isKeyEnter } from '../../../utils/keyEvent';
|
||||
import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
import style from './NavigationMenuItem.module.scss';
|
||||
@@ -11,6 +11,7 @@ interface NavigationMenuItemProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
|
||||
export default function NavigationMenuItem({
|
||||
active,
|
||||
className,
|
||||
@@ -24,7 +25,9 @@ export default function NavigationMenuItem({
|
||||
role='button'
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
if (isKeyEnter(event)) {
|
||||
if (isKeyEnter(event) || isKeySpace(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import Switch from '../../switch/Switch';
|
||||
|
||||
import style from './NavigationMenuItem.module.scss';
|
||||
|
||||
interface NavigationMenuToggleProps {
|
||||
checked: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/** A menu row which reflects, and toggles, an on/off state */
|
||||
export default function NavigationMenuToggle({ checked, icon, label, onToggle }: NavigationMenuToggleProps) {
|
||||
return (
|
||||
<label className={style.link}>
|
||||
{icon}
|
||||
<span className={style.label}>{label}</span>
|
||||
<Switch checked={checked} onCheckedChange={onToggle} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
+34
-4
@@ -1,22 +1,52 @@
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px solid $white-10;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: calc(1rem - 2px);
|
||||
margin-left: 1rem;
|
||||
padding: 0 1.25rem 0.25rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.interfaces {
|
||||
padding: 0.5rem 0.5rem;
|
||||
padding: 0.25rem 1.25rem 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.interface {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
|
||||
> div {
|
||||
max-width: 100%;
|
||||
|
||||
> button:first-child {
|
||||
min-width: 0;
|
||||
max-width: calc(100% - 1.5rem);
|
||||
justify-content: flex-start;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.interfaceCopy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.address {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.goIcon {
|
||||
flex: 0 0 auto;
|
||||
@include rotate-fourty-five;
|
||||
margin-left: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
+18
-15
@@ -3,6 +3,8 @@ import { IoArrowUp } from 'react-icons/io5';
|
||||
import useInfo from '../../../hooks-query/useInfo';
|
||||
import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
|
||||
import CopyTag from '../../copy-tag/CopyTag';
|
||||
import Eyebrow from '../../eyebrow/Eyebrow';
|
||||
import { getExternalInterfaces } from './otherAddresses.utils';
|
||||
|
||||
import style from './OtherAddresses.module.scss';
|
||||
|
||||
@@ -12,32 +14,33 @@ interface OtherAddressesProps {
|
||||
|
||||
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
|
||||
const { data } = useInfo();
|
||||
const externalInterfaces = getExternalInterfaces(data.networkInterfaces);
|
||||
|
||||
// there is no point showing this if we only have one interface
|
||||
if (data.networkInterfaces.length < 2) {
|
||||
if (externalInterfaces.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={style.header}>Accessible on external networks</div>
|
||||
<div className={style.footer}>
|
||||
<div className={style.header}>
|
||||
<Eyebrow>Also available at</Eyebrow>
|
||||
</div>
|
||||
<div className={style.interfaces}>
|
||||
{data?.networkInterfaces?.map((nif) => {
|
||||
if (nif.name === 'localhost') {
|
||||
return null;
|
||||
}
|
||||
|
||||
{externalInterfaces.map((nif) => {
|
||||
const address = linkToOtherHost(nif.address, currentLocation);
|
||||
|
||||
return (
|
||||
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
|
||||
<span className={style.interfaceCopy}>
|
||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||
</span>
|
||||
</CopyTag>
|
||||
<div key={nif.name} className={style.interface}>
|
||||
<CopyTag copyValue={address} onClick={() => openLink(address)} size='small'>
|
||||
<span className={style.interfaceCopy}>
|
||||
<span className={style.address}>{nif.address}</span>
|
||||
<IoArrowUp className={style.goIcon} />
|
||||
</span>
|
||||
</CopyTag>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { getExternalInterfaces } from '../otherAddresses.utils';
|
||||
|
||||
describe('getExternalInterfaces', () => {
|
||||
test('returns no alternatives when localhost is the only interface', () => {
|
||||
expect(getExternalInterfaces([{ name: 'localhost', address: '127.0.0.1' }])).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not treat an interface name as deployment information', () => {
|
||||
expect(
|
||||
getExternalInterfaces([
|
||||
{ name: 'localhost', address: '127.0.0.1' },
|
||||
{ name: 'cloud', address: '192.168.1.42' },
|
||||
]),
|
||||
).toEqual([{ name: 'cloud', address: '192.168.1.42' }]);
|
||||
});
|
||||
|
||||
test('returns non-local interfaces when local and external interfaces are available', () => {
|
||||
expect(
|
||||
getExternalInterfaces([
|
||||
{ name: 'localhost', address: '127.0.0.1' },
|
||||
{ name: 'Wi-Fi', address: '192.168.1.42' },
|
||||
{ name: 'Ethernet', address: '10.0.0.42' },
|
||||
]),
|
||||
).toEqual([
|
||||
{ name: 'Wi-Fi', address: '192.168.1.42' },
|
||||
{ name: 'Ethernet', address: '10.0.0.42' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { type NetworkInterface } from 'ontime-types';
|
||||
|
||||
/** Returns network addresses other than the localhost address injected by the info endpoint. */
|
||||
export function getExternalInterfaces(interfaces: NetworkInterface[]) {
|
||||
return interfaces.filter((networkInterface) => networkInterface.name !== 'localhost');
|
||||
}
|
||||
@@ -2,6 +2,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
> span {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
// attempt to match with ontimeTextInputs
|
||||
|
||||
@@ -1,37 +1,77 @@
|
||||
.inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
.multiOptionList {
|
||||
overflow-y: auto;
|
||||
max-height: 12rem;
|
||||
background: $gray-1200;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: $component-border-radius-md;
|
||||
}
|
||||
|
||||
// styles from subtle button
|
||||
.toggleSelect {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding-inline: 0.5rem;
|
||||
height: 2.5rem;
|
||||
background: $gray-1050;
|
||||
color: $ui-white;
|
||||
gap: 0.75rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: $gray-300;
|
||||
line-height: 1em;
|
||||
border-radius: $component-border-radius-md;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color $transition-time-action,
|
||||
border-color $transition-time-action,
|
||||
color $transition-time-action;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
&:hover {
|
||||
background: $gray-1000;
|
||||
color: $blue-500;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
&:active {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: $bg-active;
|
||||
box-shadow: inset 3px 0 $blue-400;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $white-7;
|
||||
}
|
||||
|
||||
> [role='checkbox'] {
|
||||
flex: 0 0 auto;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border: 1px solid $gray-400;
|
||||
|
||||
&[data-unchecked] {
|
||||
background: $gray-1100;
|
||||
}
|
||||
|
||||
&[data-checked] {
|
||||
border-color: $blue-400;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
margin-left: 0.25rem;
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
background: var(--user-bg, $gray-900);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ComponentProps, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { isStringBoolean } from '../../../views/common/viewUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Checkbox from '../checkbox/Checkbox';
|
||||
import Input from '../input/input/Input';
|
||||
import Select, { SelectOption } from '../select/Select';
|
||||
@@ -107,18 +108,20 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
|
||||
return (
|
||||
<>
|
||||
<input name={id} hidden readOnly value={paramState.join(',')} />
|
||||
<div className={style.inline}>
|
||||
<div className={style.multiOptionList}>
|
||||
{values.map((option) => {
|
||||
const selected = paramState.includes(option.value);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className={style.toggleSelect}
|
||||
className={cx([style.toggleSelect, selected && style.selected])}
|
||||
style={{
|
||||
'--user-bg': option.colour,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={paramState.includes(option.value)}
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
|
||||
/>
|
||||
{option.label}
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
.drawerFooter {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
gap: $section-spacing;
|
||||
|
||||
button {
|
||||
padding: 0 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -34,11 +24,11 @@
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-block: 1rem 1.5rem;
|
||||
|
||||
background-color: $gray-1250;
|
||||
background-color: $ui-black;
|
||||
color: $ui-white;
|
||||
border-left: 1px solid $gray-1100;
|
||||
box-shadow: $box-shadow-l1;
|
||||
|
||||
&[data-open] {
|
||||
transform: translateX(0%);
|
||||
@@ -58,36 +48,54 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
padding-inline: 1rem;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 3.5rem;
|
||||
gap: 1rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.headerText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.target {
|
||||
font-size: $aux-text-size;
|
||||
color: $gray-600;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
padding-inline: 1rem;
|
||||
padding-bottom: 10vh;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
padding-inline: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
background-color: $ui-black;
|
||||
border-top: 1px solid $white-10;
|
||||
}
|
||||
|
||||
.sectionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
padding-right: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FormEvent, memo } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { viewLabels } from '../../../viewerConfig';
|
||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||
import { useSavedViewParams } from '../../stores/savedViewParams';
|
||||
@@ -71,8 +72,17 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
<Dialog.Backdrop className={style.backdrop} />
|
||||
<Dialog.Popup className={style.drawer}>
|
||||
<div className={style.header}>
|
||||
<Dialog.Title>Customise</Dialog.Title>
|
||||
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
|
||||
<div className={style.headerText}>
|
||||
<Dialog.Title className={style.title}>Customise</Dialog.Title>
|
||||
<span className={style.target}>{viewLabels[target]}</span>
|
||||
</div>
|
||||
<IconButton
|
||||
variant='subtle-white'
|
||||
size='large'
|
||||
aria-label='Close'
|
||||
data-testid='close-view-params'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
.presetSection {
|
||||
background-color: $gray-1350;
|
||||
border: 1px solid $white-10;
|
||||
border-radius: $component-border-radius-md;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 0.5rem 1rem;
|
||||
min-height: 2.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
align-items: center;
|
||||
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
.presetList {
|
||||
border-top: 1px solid $white-7;
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
@@ -14,12 +25,39 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.375rem 1rem;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $white-7;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $blue-500;
|
||||
background-color: $bg-active;
|
||||
box-shadow: inset 3px 0 $blue-400;
|
||||
color: $ui-white;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.presetName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.presetActions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.current {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
color: $blue-400;
|
||||
font-size: $aux-text-size;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router';
|
||||
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Button from '../buttons/Button';
|
||||
import Eyebrow from '../eyebrow/Eyebrow';
|
||||
|
||||
import style from './ViewParamsPresets.module.scss';
|
||||
|
||||
@@ -26,22 +27,31 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
|
||||
|
||||
return (
|
||||
<div className={style.presetSection}>
|
||||
{viewPresets.map((preset) => {
|
||||
const active = searchParams.get('alias') === preset.alias;
|
||||
return (
|
||||
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
||||
<div>{preset.alias}</div>
|
||||
<Button
|
||||
variant={active ? 'ghosted' : 'subtle-white'}
|
||||
onClick={() => handleRecall(preset)}
|
||||
disabled={active}
|
||||
className={style.presetActions}
|
||||
>
|
||||
{active ? 'Applied' : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={style.header}>
|
||||
<Eyebrow>Saved presets</Eyebrow>
|
||||
</div>
|
||||
<div className={style.presetList}>
|
||||
{viewPresets.map((preset) => {
|
||||
const active = searchParams.get('alias') === preset.alias;
|
||||
return (
|
||||
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
||||
<span className={style.presetName}>{preset.alias}</span>
|
||||
{active ? (
|
||||
<span className={style.current}>Current</span>
|
||||
) : (
|
||||
<Button
|
||||
variant='ghosted'
|
||||
size='small'
|
||||
onClick={() => handleRecall(preset)}
|
||||
className={style.presetActions}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+92
-22
@@ -1,48 +1,118 @@
|
||||
.section {
|
||||
overflow: hidden;
|
||||
|
||||
color: $ui-white;
|
||||
font-size: 1rem;
|
||||
background-color: $white-3;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: $component-border-radius-md;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
|
||||
color: $gray-300;
|
||||
|
||||
&.collapsible {
|
||||
cursor: pointer;
|
||||
transition-property: color, background-color;
|
||||
transition-duration: $transition-time-action;
|
||||
|
||||
&:hover {
|
||||
background-color: $white-3;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/** marks a section which holds values coming from the URL */
|
||||
.customised {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background-color: $active-indicator;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
rotate: 180deg;
|
||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
|
||||
&.closed {
|
||||
rotate: 0deg;
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
padding-inline: 1rem;
|
||||
border-top: 1px solid $white-7;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-top: $section-spacing;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 0.25rem;
|
||||
padding: 0.75rem 0;
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $white-7;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact controls sit beside their label instead of below it.
|
||||
* Placement is done with grid areas so that the markup stays flat.
|
||||
*/
|
||||
.inline {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
column-gap: 1rem;
|
||||
|
||||
> .title {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
> .description {
|
||||
grid-area: 2 / 1;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
grid-area: 1 / 2 / span 2;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
color: $modal-note-color;
|
||||
}
|
||||
|
||||
.closed {
|
||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
rotate: 0deg;
|
||||
}
|
||||
|
||||
.open {
|
||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
rotate: 180deg;
|
||||
color: $gray-400;
|
||||
font-size: $aux-text-size;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { IoChevronDown } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Eyebrow from '../eyebrow/Eyebrow';
|
||||
import { OptionTitle } from './constants';
|
||||
import ParamInput from './ParamInput';
|
||||
import { type ParamField } from './viewParams.types';
|
||||
@@ -14,50 +16,74 @@ interface ViewParamsSectionProps {
|
||||
options: ParamField[];
|
||||
}
|
||||
|
||||
/** Fields which are compact enough to share a row with their label */
|
||||
function isInlineField(option: ParamField): boolean {
|
||||
return option.type === 'boolean' || option.type === 'colour';
|
||||
}
|
||||
|
||||
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
||||
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const handleCollapse = () => {
|
||||
if (collapsible) {
|
||||
setCollapsed((prev) => !prev);
|
||||
}
|
||||
};
|
||||
// hidden options are inputs without a UI, there is nothing to frame them with
|
||||
if (title === OptionTitle.Hidden) {
|
||||
return <HiddenContents options={options} />;
|
||||
}
|
||||
|
||||
const isCollapsed = Boolean(collapsible && collapsed);
|
||||
|
||||
return (
|
||||
<section className={style.section}>
|
||||
{title === OptionTitle.Hidden ? (
|
||||
<HiddenContents options={options} />
|
||||
) : (
|
||||
<>
|
||||
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
|
||||
{title}
|
||||
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
|
||||
</div>
|
||||
<SectionContents options={options} collapsed={collapsed} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionContentsProps {
|
||||
options: ParamField[];
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
function SectionContents({ options, collapsed }: SectionContentsProps) {
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
isCustomised={options.some((option) => searchParams.has(option.id))}
|
||||
collapsed={isCollapsed}
|
||||
onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined}
|
||||
/>
|
||||
{/* collapsed options stay mounted: the form reads its values from the DOM */}
|
||||
<div className={cx([style.options, isCollapsed && style.hidden])}>
|
||||
{options.map((option) => (
|
||||
<label key={option.title} className={cx([style.label, isInlineField(option) && style.inline])}>
|
||||
<span className={style.title}>{option.title}</span>
|
||||
<span className={style.description}>{option.description}</span>
|
||||
<ParamInput paramField={option} />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
isCustomised: boolean;
|
||||
collapsed: boolean;
|
||||
/** a header without a toggle belongs to a section which cannot collapse */
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
function SectionHeader({ title, isCustomised, collapsed, onToggle }: SectionHeaderProps) {
|
||||
const label = (
|
||||
<Eyebrow className={style.sectionTitle}>
|
||||
{title}
|
||||
{isCustomised && <span className={style.customised} title='Contains custom values' />}
|
||||
</Eyebrow>
|
||||
);
|
||||
|
||||
if (!onToggle) {
|
||||
return <div className={style.sectionHeader}>{label}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={cx([style.sectionHeader, style.collapsible])}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{label}
|
||||
<IoChevronDown className={cx([style.chevron, collapsed && style.closed])} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Playback, RuntimeStore, TimerPhase, TimerType, runtimeStorePlaceholder } from 'ontime-types';
|
||||
|
||||
import { resolveTimerDisplay } from '../useSocket.utils';
|
||||
|
||||
const eventTimer = { ...runtimeStorePlaceholder.timer, current: 5_000 };
|
||||
const groupTimer = {
|
||||
...runtimeStorePlaceholder.timer,
|
||||
current: 25_000,
|
||||
phase: TimerPhase.Default,
|
||||
playback: Playback.Play,
|
||||
};
|
||||
|
||||
function makeState(patch: Partial<RuntimeStore> = {}): RuntimeStore {
|
||||
return {
|
||||
...runtimeStorePlaceholder,
|
||||
timer: eventTimer,
|
||||
eventNow: {
|
||||
id: 'event-1',
|
||||
timerType: TimerType.CountUp,
|
||||
countToEnd: true,
|
||||
} as RuntimeStore['eventNow'],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveTimerDisplay()', () => {
|
||||
it('uses the event timer by default', () => {
|
||||
expect(resolveTimerDisplay(makeState())).toMatchObject({
|
||||
time: eventTimer,
|
||||
timerType: TimerType.CountUp,
|
||||
countToEnd: true,
|
||||
usesGroupTimer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the group timer and display type when enabled', () => {
|
||||
const display = resolveTimerDisplay(
|
||||
makeState({
|
||||
groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
|
||||
groupTimer,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(display).toMatchObject({
|
||||
time: groupTimer,
|
||||
timerType: TimerType.CountDown,
|
||||
countToEnd: false,
|
||||
usesGroupTimer: true,
|
||||
eventTimer,
|
||||
eventTimerType: TimerType.CountUp,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a group timer when the group setting is disabled', () => {
|
||||
const display = resolveTimerDisplay(
|
||||
makeState({
|
||||
groupNow: { useGroupTimer: false, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
|
||||
groupTimer,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(display.time).toBe(eventTimer);
|
||||
expect(display.usesGroupTimer).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back entirely to the event display while group timer data is unavailable', () => {
|
||||
const display = resolveTimerDisplay(
|
||||
makeState({
|
||||
groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
|
||||
groupTimer: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(display).toMatchObject({
|
||||
time: eventTimer,
|
||||
timerType: TimerType.CountUp,
|
||||
countToEnd: true,
|
||||
usesGroupTimer: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage, TimerType } from 'ontime-types';
|
||||
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||
|
||||
import { useRuntimeStore } from '../stores/runtime';
|
||||
import { sendSocket } from '../utils/socket';
|
||||
import { resolveTimerDisplay } from './useSocket.utils';
|
||||
|
||||
const createSelector =
|
||||
<T>(selector: (state: RuntimeStore) => T) =>
|
||||
@@ -38,15 +39,19 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
|
||||
visible: state.message.timer.secondarySource === 'secondary',
|
||||
}));
|
||||
|
||||
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
|
||||
blink: state.message.timer.blink,
|
||||
blackout: state.message.timer.blackout,
|
||||
phase: state.timer.phase,
|
||||
secondarySource: state.message.timer.secondarySource,
|
||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
||||
timerType: state.eventNow?.timerType ?? null,
|
||||
countToEnd: state.eventNow?.countToEnd ?? false,
|
||||
}));
|
||||
export const useMessagePreview = createSelector((state: RuntimeStore) => {
|
||||
const timerDisplay = resolveTimerDisplay(state);
|
||||
return {
|
||||
blink: state.message.timer.blink,
|
||||
blackout: state.message.timer.blackout,
|
||||
phase: timerDisplay.time.phase,
|
||||
secondarySource: state.message.timer.secondarySource,
|
||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
||||
timerType: timerDisplay.timerType,
|
||||
countToEnd: timerDisplay.countToEnd,
|
||||
usesGroupTimer: timerDisplay.usesGroupTimer,
|
||||
};
|
||||
});
|
||||
|
||||
export const setMessage = {
|
||||
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
|
||||
@@ -230,20 +235,26 @@ export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||
|
||||
/* ======================= View specific subscriptions ======================= */
|
||||
|
||||
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
|
||||
eventNext: state.eventNext,
|
||||
eventNow: state.eventNow,
|
||||
message: state.message,
|
||||
time: state.timer,
|
||||
clock: state.clock,
|
||||
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
|
||||
countToEndNow: state.eventNow?.countToEnd ?? false,
|
||||
auxTimer: {
|
||||
aux1: state.auxtimer1.current,
|
||||
aux2: state.auxtimer2.current,
|
||||
aux3: state.auxtimer3.current,
|
||||
},
|
||||
}));
|
||||
export const useTimerSocket = createSelector((state: RuntimeStore) => {
|
||||
const timerDisplay = resolveTimerDisplay(state);
|
||||
return {
|
||||
eventNext: state.eventNext,
|
||||
eventNow: state.eventNow,
|
||||
message: state.message,
|
||||
time: timerDisplay.time,
|
||||
eventTimer: timerDisplay.eventTimer,
|
||||
clock: state.clock,
|
||||
timerTypeNow: timerDisplay.timerType,
|
||||
eventTimerType: timerDisplay.eventTimerType,
|
||||
countToEndNow: timerDisplay.countToEnd,
|
||||
usesGroupTimer: timerDisplay.usesGroupTimer,
|
||||
auxTimer: {
|
||||
aux1: state.auxtimer1.current,
|
||||
aux2: state.auxtimer2.current,
|
||||
aux3: state.auxtimer3.current,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const useCountdownSocket = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { RuntimeStore, TimerType } from 'ontime-types';
|
||||
|
||||
type TimerDisplaySource = Pick<RuntimeStore, 'eventNow' | 'groupNow' | 'groupTimer' | 'timer'>;
|
||||
|
||||
export function resolveTimerDisplay(state: TimerDisplaySource) {
|
||||
const eventTimerType = state.eventNow?.timerType ?? TimerType.CountDown;
|
||||
|
||||
if (state.groupNow?.useGroupTimer === true && state.groupTimer !== null) {
|
||||
return {
|
||||
time: state.groupTimer,
|
||||
timerType: state.groupNow.timerType,
|
||||
countToEnd: false,
|
||||
usesGroupTimer: true,
|
||||
eventTimer: state.timer,
|
||||
eventTimerType,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
time: state.timer,
|
||||
timerType: eventTimerType,
|
||||
countToEnd: state.eventNow?.countToEnd ?? false,
|
||||
usesGroupTimer: false,
|
||||
eventTimer: state.timer,
|
||||
eventTimerType,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
||||
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types';
|
||||
|
||||
import { initRundownMetadata } from '../rundownMetadata';
|
||||
import { getFlatRundownMetadata, initRundownMetadata } from '../rundownMetadata';
|
||||
|
||||
describe('initRundownMetadata()', () => {
|
||||
it('processes nested rundown data', () => {
|
||||
@@ -300,3 +300,36 @@ describe('initRundownMetadata()', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFlatRundownMetadata()', () => {
|
||||
it('exposes group timer settings on a group and its events', () => {
|
||||
const group = {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
entries: ['event'],
|
||||
colour: 'red',
|
||||
useGroupTimer: true,
|
||||
timerType: TimerType.CountUp,
|
||||
} as OntimeGroup;
|
||||
const event = {
|
||||
id: 'event',
|
||||
type: SupportedEntry.Event,
|
||||
parent: group.id,
|
||||
timeStart: 0,
|
||||
timeEnd: 1,
|
||||
duration: 1,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
skip: false,
|
||||
linkStart: false,
|
||||
} as OntimeEvent;
|
||||
|
||||
const flat = getFlatRundownMetadata(
|
||||
{ entries: { [group.id]: group, [event.id]: event }, flatOrder: [group.id, event.id] },
|
||||
null,
|
||||
);
|
||||
|
||||
expect(flat[0]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
|
||||
expect(flat[1]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,10 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === 'Enter';
|
||||
}
|
||||
|
||||
export function isKeySpace<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === ' ' || event.code === 'Space';
|
||||
}
|
||||
|
||||
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === 'Escape';
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
PlayableEvent,
|
||||
Rundown,
|
||||
TimerType,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
isPlayableEvent,
|
||||
@@ -29,7 +31,11 @@ export type RundownMetadata = {
|
||||
isFirstAfterGroup: boolean;
|
||||
};
|
||||
|
||||
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
|
||||
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T &
|
||||
RundownMetadata & {
|
||||
groupUsesTimer?: boolean;
|
||||
groupTimerType?: TimerType;
|
||||
};
|
||||
|
||||
export const lastMetadataKey = 'LAST';
|
||||
|
||||
@@ -65,10 +71,23 @@ export function getFlatRundownMetadata(
|
||||
): ExtendedEntry[] {
|
||||
const { process } = initRundownMetadata(selectedEventId);
|
||||
const flatRundown: ExtendedEntry[] = [];
|
||||
let activeGroup: OntimeGroup | null = null;
|
||||
|
||||
for (const id of data.flatOrder) {
|
||||
const entry = data.entries[id];
|
||||
const extendedEntry = { ...entry, ...process(entry) };
|
||||
if (isOntimeGroup(entry)) {
|
||||
activeGroup = entry;
|
||||
} else if (entry.parent !== activeGroup?.id) {
|
||||
activeGroup = null;
|
||||
}
|
||||
|
||||
const timerGroup = isOntimeGroup(entry) ? entry : activeGroup;
|
||||
const extendedEntry = {
|
||||
...entry,
|
||||
...process(entry),
|
||||
groupUsesTimer: timerGroup?.useGroupTimer ?? false,
|
||||
groupTimerType: timerGroup?.timerType,
|
||||
};
|
||||
flatRundown.push(extendedEntry);
|
||||
}
|
||||
|
||||
|
||||
-35
@@ -1,5 +1,3 @@
|
||||
import { AppMode } from '../ontimeConfig';
|
||||
|
||||
declare module '*.scss' {
|
||||
const content: Record<string, string>;
|
||||
export default content;
|
||||
@@ -32,39 +30,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare custom data we pass to the table
|
||||
* - `handleUpdate` callback to update the entry when the user edits a cell
|
||||
* - `handleUpdateTimer` callback to update the timer for a specific event
|
||||
* - `options-showDelayedTimes` whether to show or hide delayed times
|
||||
* - `options-hideTableSeconds` whether to hide seconds in the table
|
||||
* - `options-hideIndexColumn` whether to hide the index column
|
||||
* - `options-cuesheetMode` run or edit mode
|
||||
*
|
||||
* And metadata specific for each column
|
||||
* - `canWrite` whether the user can write to this column
|
||||
* - `colour` background colour associated with a custom field
|
||||
*/
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface TableMeta<TData extends RowData> {
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
|
||||
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
|
||||
options: {
|
||||
showDelayedTimes: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
cuesheetMode: AppMode;
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
canWrite: boolean;
|
||||
colour?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow passing CSS Properties
|
||||
*/
|
||||
|
||||
@@ -53,7 +53,7 @@ ul {
|
||||
&.active {
|
||||
color: $ui-white;
|
||||
border-left-color: $blue-400;
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
background-color: $bg-active;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ ul {
|
||||
&.active {
|
||||
color: $ui-white;
|
||||
border-left-color: $blue-400;
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
background-color: $bg-active;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ $card-padding: 2rem;
|
||||
// active rows use a panel-local class today; keep the treatment here so all
|
||||
// settings tables share the same state without each panel owning its colours
|
||||
tr[class*='current'] {
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
background-color: $bg-active;
|
||||
|
||||
> td:first-child {
|
||||
box-shadow: inset 3px 0 $blue-400;
|
||||
|
||||
+9
-8
@@ -12,20 +12,21 @@ import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPres
|
||||
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||
import { viewLabels } from '../../../../../viewerConfig';
|
||||
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './URLPresetForm.module.scss';
|
||||
|
||||
const targetOptions: SelectOption<OntimeViewPresettable>[] = [
|
||||
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
||||
{ value: OntimeView.Operator, label: 'Operator' },
|
||||
{ value: OntimeView.Timer, label: 'Timer' },
|
||||
{ value: OntimeView.Backstage, label: 'Backstage' },
|
||||
{ value: OntimeView.Timeline, label: 'Timeline' },
|
||||
{ value: OntimeView.StudioClock, label: 'Studio Clock' },
|
||||
{ value: OntimeView.Countdown, label: 'Countdown' },
|
||||
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
|
||||
{ value: OntimeView.Cuesheet, label: viewLabels[OntimeView.Cuesheet] },
|
||||
{ value: OntimeView.Operator, label: viewLabels[OntimeView.Operator] },
|
||||
{ value: OntimeView.Timer, label: viewLabels[OntimeView.Timer] },
|
||||
{ value: OntimeView.Backstage, label: viewLabels[OntimeView.Backstage] },
|
||||
{ value: OntimeView.Timeline, label: viewLabels[OntimeView.Timeline] },
|
||||
{ value: OntimeView.StudioClock, label: viewLabels[OntimeView.StudioClock] },
|
||||
{ value: OntimeView.Countdown, label: viewLabels[OntimeView.Countdown] },
|
||||
{ value: OntimeView.ProjectInfo, label: viewLabels[OntimeView.ProjectInfo] },
|
||||
];
|
||||
|
||||
const formId = 'url-preset-form';
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
border-top: 1px solid $white-7;
|
||||
}
|
||||
|
||||
.timerSource {
|
||||
color: $active-indicator;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.blackout {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TimerPhase, TimerType } from 'ontime-types';
|
||||
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
|
||||
import { IoArrowDown, IoArrowUp, IoBan, IoTime, IoTimerOutline } from 'react-icons/io5';
|
||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||
|
||||
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
||||
@@ -20,7 +20,8 @@ const secondarySourceLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function TimerPreview() {
|
||||
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
|
||||
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType, usesGroupTimer } =
|
||||
useMessagePreview();
|
||||
const { data } = useViewSettings();
|
||||
|
||||
const main = (() => {
|
||||
@@ -35,7 +36,9 @@ export default function TimerPreview() {
|
||||
|
||||
const secondary = (() => {
|
||||
// message is a fullscreen overlay or secondary is not active
|
||||
if (showTimerMessage || !secondarySource) return null;
|
||||
if (showTimerMessage) return null;
|
||||
if (usesGroupTimer) return 'Event timer';
|
||||
if (!secondarySource) return null;
|
||||
|
||||
// we need to check aux first since it takes priority
|
||||
return secondarySourceLabels[secondarySource];
|
||||
@@ -55,6 +58,7 @@ export default function TimerPreview() {
|
||||
<div className={style.preview}>
|
||||
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
|
||||
<div className={contentClasses}>
|
||||
{usesGroupTimer && <div className={style.timerSource}>Group timer</div>}
|
||||
<div
|
||||
className={style.mainContent}
|
||||
data-phase={showColourOverride && phase}
|
||||
@@ -65,6 +69,14 @@ export default function TimerPreview() {
|
||||
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
|
||||
</div>
|
||||
<div className={style.eventStatus}>
|
||||
<Tooltip
|
||||
text='Timer display controlled by group'
|
||||
render={<span />}
|
||||
className={style.statusIcon}
|
||||
data-active={usesGroupTimer}
|
||||
>
|
||||
<IoTimerOutline />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
text='Time type: Count down'
|
||||
render={<span />}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useState } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import ToggleButton from '../../common/components/buttons/ToggleButton';
|
||||
import { clearLogs, useLogData } from '../../common/stores/logger';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||
@@ -55,72 +56,60 @@ export default function Log() {
|
||||
<div className={cx([style.container, isExtracted && style.extracted])}>
|
||||
<Panel.InlineElements className={style.buttonBar}>
|
||||
<span className={style.filterLabel}>Filter by</span>
|
||||
<Button
|
||||
variant={showUser ? 'primary' : 'subtle'}
|
||||
<ToggleButton
|
||||
pressed={showUser}
|
||||
size='small'
|
||||
aria-pressed={showUser}
|
||||
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
|
||||
onClick={() => setShowUser((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.User)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.User}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showClient ? 'primary' : 'subtle'}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
pressed={showClient}
|
||||
size='small'
|
||||
aria-pressed={showClient}
|
||||
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
|
||||
onClick={() => setShowClient((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Client)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Client}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showServer ? 'primary' : 'subtle'}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
pressed={showServer}
|
||||
size='small'
|
||||
aria-pressed={showServer}
|
||||
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
|
||||
onClick={() => setShowServer((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Server)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Server}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showPlayback ? 'primary' : 'subtle'}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
pressed={showPlayback}
|
||||
size='small'
|
||||
aria-pressed={showPlayback}
|
||||
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
|
||||
onClick={() => setShowPlayback((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Playback)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Playback}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showRx ? 'primary' : 'subtle'}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
pressed={showRx}
|
||||
size='small'
|
||||
aria-pressed={showRx}
|
||||
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
|
||||
onClick={() => setShowRx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Rx)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Rx}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showTx ? 'primary' : 'subtle'}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
pressed={showTx}
|
||||
size='small'
|
||||
aria-pressed={showTx}
|
||||
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
|
||||
onClick={() => setShowTx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Tx)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Tx}
|
||||
</Button>
|
||||
</ToggleButton>
|
||||
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
|
||||
<IoClose /> Clear
|
||||
</Button>
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.timerDisplaySettings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { MaybeNumber, OntimeGroup } from 'ontime-types';
|
||||
import { MaybeNumber, OntimeGroup, TimerType } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
|
||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||
import Select from '../../../common/components/select/Select';
|
||||
import Switch from '../../../common/components/switch/Switch';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import { getOffsetState } from '../../../common/utils/offset';
|
||||
@@ -107,6 +109,38 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
||||
<EventTextArea field='note' label='Note' initialValue={group.note} submitHandler={handleSubmit} />
|
||||
</div>
|
||||
|
||||
<div className={style.column}>
|
||||
<Editor.Title>Timer display</Editor.Title>
|
||||
<div className={style.timerDisplaySettings}>
|
||||
<div>
|
||||
<Editor.Label htmlFor='useGroupTimer'>Use group timer</Editor.Label>
|
||||
<Editor.Label className={style.switchLabel}>
|
||||
<Switch
|
||||
id='useGroupTimer'
|
||||
checked={group.useGroupTimer}
|
||||
onCheckedChange={(useGroupTimer) => updateEntry({ id: group.id, useGroupTimer })}
|
||||
/>
|
||||
{group.useGroupTimer ? 'On' : 'Off'}
|
||||
</Editor.Label>
|
||||
</div>
|
||||
<div>
|
||||
<Editor.Label htmlFor='groupTimerType'>Timer type</Editor.Label>
|
||||
<Select
|
||||
id='groupTimerType'
|
||||
disabled={!group.useGroupTimer}
|
||||
value={group.timerType}
|
||||
onValueChange={(timerType: TimerType | null) => {
|
||||
if (timerType !== null) updateEntry({ id: group.id, timerType });
|
||||
}}
|
||||
options={[
|
||||
{ value: TimerType.CountDown, label: 'Count down' },
|
||||
{ value: TimerType.CountUp, label: 'Count up' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
Custom Fields
|
||||
|
||||
@@ -8,10 +8,16 @@ export default memo(FinderPlacement);
|
||||
function FinderPlacement() {
|
||||
const [isOpen, handler] = useDisclosure();
|
||||
|
||||
useHotkeys([
|
||||
['mod + f', handler.toggle, { preventDefault: true }],
|
||||
['Escape', handler.close, { preventDefault: true }],
|
||||
]);
|
||||
/**
|
||||
* The empty tagsToIgnore is significant: by default the hook skips input elements,
|
||||
* which would make the shortcut dead while editing an entry.
|
||||
*
|
||||
* This opens rather than toggles. Toggling on a key that also mounts and unmounts the
|
||||
* dialog races against it, and browsers treat a repeated find shortcut as "focus the
|
||||
* search again" rather than "close it". The finder selects its input instead, and
|
||||
* Escape closes.
|
||||
*/
|
||||
useHotkeys([['mod + f', handler.open, { preventDefault: true }]], []);
|
||||
|
||||
if (isOpen) {
|
||||
return <Finder isOpen={isOpen} onClose={handler.close} />;
|
||||
|
||||
@@ -312,6 +312,7 @@ export default function RundownEvent({
|
||||
onClick={handleFocusClick}
|
||||
onContextMenu={onContextMenu}
|
||||
data-testid='rundown-event'
|
||||
data-selected={isSelected}
|
||||
{...(isPlaying ? { 'data-running': true } : {})}
|
||||
>
|
||||
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
|
||||
@@ -334,6 +335,7 @@ export default function RundownEvent({
|
||||
eventIndex={eventIndex}
|
||||
endAction={endAction}
|
||||
timerType={timerType}
|
||||
groupTimerType={parentGroup?.useGroupTimer ? parentGroup.timerType : undefined}
|
||||
title={title}
|
||||
note={note}
|
||||
delay={delay}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IoPlayForward,
|
||||
IoPlaySkipForward,
|
||||
IoTime,
|
||||
IoTimerOutline,
|
||||
} from 'react-icons/io5';
|
||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||
|
||||
@@ -35,6 +36,7 @@ interface RundownEventInnerProps {
|
||||
eventIndex: number;
|
||||
endAction: EndAction;
|
||||
timerType: TimerType;
|
||||
groupTimerType?: TimerType;
|
||||
title: string;
|
||||
note: string;
|
||||
delay: number;
|
||||
@@ -62,6 +64,7 @@ function RundownEventInner({
|
||||
countToEnd,
|
||||
endAction,
|
||||
timerType,
|
||||
groupTimerType,
|
||||
title,
|
||||
note,
|
||||
delay,
|
||||
@@ -150,6 +153,14 @@ function RundownEventInner({
|
||||
{loaded && <EventBlockProgressBar />}
|
||||
</div>
|
||||
<div className={style.eventStatus} tabIndex={-1}>
|
||||
{groupTimerType && (
|
||||
<Tooltip
|
||||
text={`Timer display controlled by group (${groupTimerType === TimerType.CountUp ? 'count up' : 'count down'})`}
|
||||
render={<span />}
|
||||
>
|
||||
<IoTimerOutline className={cx([style.statusIcon, style.active])} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip text={`Time type: ${timerType}`} render={<span />}>
|
||||
<TimerIcon type={timerType} className={style.statusIcon} />
|
||||
</Tooltip>
|
||||
|
||||
@@ -55,6 +55,14 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.timerIndicator {
|
||||
display: grid;
|
||||
flex: 0 0 1.5rem;
|
||||
place-items: center;
|
||||
color: $active-indicator;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
gap: $block-clearance; // same as RundownEvent.eventTimers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EntryId, OntimeGroup } from 'ontime-types';
|
||||
import { EntryId, OntimeGroup, TimerType } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
import { MouseEvent, useCallback, useRef } from 'react';
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IoDuplicateOutline,
|
||||
IoFolderOpenOutline,
|
||||
IoReorderTwo,
|
||||
IoTimerOutline,
|
||||
IoTrash,
|
||||
IoLockClosed,
|
||||
} from 'react-icons/io5';
|
||||
@@ -174,6 +175,14 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
<div className={style.header}>
|
||||
<div className={style.titleRow}>
|
||||
<TitleEditor title={data.title} entryId={data.id} placeholder='Group title' />
|
||||
{data.useGroupTimer && (
|
||||
<Tooltip
|
||||
text={`Group timer (${data.timerType === TimerType.CountUp ? 'count up' : 'count down'})`}
|
||||
render={<span className={style.timerIndicator} />}
|
||||
>
|
||||
<IoTimerOutline />
|
||||
</Tooltip>
|
||||
)}
|
||||
<IconButton aria-label='Collapse' variant='subtle-white' onClick={() => onCollapse(!collapsed, data.id)}>
|
||||
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
|
||||
</IconButton>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { CustomFields } from 'ontime-types';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { makeCuesheetColumns } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||
import type { CuesheetColumnDef } from '../../../views/cuesheet/cuesheet-table/cuesheetTable.features';
|
||||
|
||||
/**
|
||||
* Creates column definitions for the rundown table
|
||||
* Reuses cuesheetColsFactory with preset=undefined for full access
|
||||
*/
|
||||
export function makeRundownColumns(customFields: CustomFields): ColumnDef<ExtendedEntry>[] {
|
||||
export function makeRundownColumns(customFields: CustomFields): CuesheetColumnDef[] {
|
||||
// When preset=undefined, factory defaults to fullRead=true, fullWrite=true
|
||||
// canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run)
|
||||
return makeCuesheetColumns(customFields, AppMode.Edit, undefined);
|
||||
|
||||
@@ -48,6 +48,7 @@ $playback-under: $green-500;
|
||||
// interface panels
|
||||
$bg-container-l1: $gray-1350;
|
||||
$bg-container-l2: $gray-1300;
|
||||
$bg-active: rgba($action-blue, 0.16);
|
||||
|
||||
$backdrop-color: rgba(0, 0, 0, 0.7);
|
||||
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
export const navigatorConstants = [
|
||||
{ url: 'timer', label: 'Timer' },
|
||||
{ url: 'backstage', label: 'Backstage' },
|
||||
{ url: 'timeline', label: 'Timeline' },
|
||||
{ url: 'studio', label: 'Studio Clock' },
|
||||
{ url: 'countdown', label: 'Countdown' },
|
||||
{ url: 'info', label: 'Project Info' },
|
||||
import { OntimeView } from 'ontime-types';
|
||||
|
||||
/** User facing name of each view, the single source for view naming in the UI */
|
||||
export const viewLabels: Record<OntimeView, string> = {
|
||||
[OntimeView.Editor]: 'Editor',
|
||||
[OntimeView.Cuesheet]: 'Cuesheet',
|
||||
[OntimeView.Operator]: 'Operator',
|
||||
[OntimeView.Timer]: 'Timer',
|
||||
[OntimeView.Backstage]: 'Backstage',
|
||||
[OntimeView.Timeline]: 'Timeline',
|
||||
[OntimeView.StudioClock]: 'Studio Clock',
|
||||
[OntimeView.Countdown]: 'Countdown',
|
||||
[OntimeView.ProjectInfo]: 'Project Info',
|
||||
};
|
||||
|
||||
/** Views offered in the navigation menu, in the order they are shown. Their value is also their route */
|
||||
const navigatorViews = [
|
||||
OntimeView.Timer,
|
||||
OntimeView.Backstage,
|
||||
OntimeView.Timeline,
|
||||
OntimeView.StudioClock,
|
||||
OntimeView.Countdown,
|
||||
OntimeView.ProjectInfo,
|
||||
];
|
||||
|
||||
export const navigatorConstants = navigatorViews.map((view) => ({ url: view, label: viewLabels[view] }));
|
||||
|
||||
// default time format to use for users in 12 hour clocks
|
||||
export const FORMAT_12 = 'h:mm:ss a';
|
||||
// default time format to use for users in 24 hour clocks
|
||||
|
||||
@@ -7,14 +7,13 @@ import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import type { CuesheetColumnDef } from '../cuesheet-table/cuesheetTable.features';
|
||||
import { useColumnOrder } from '../cuesheet-table/useColumnManager';
|
||||
|
||||
interface CuesheetDndProps {
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
columns: CuesheetColumnDef[];
|
||||
tableRoot?: 'editor' | 'cuesheet';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTableNav } from '@table-nav/react';
|
||||
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { useTable } from '@tanstack/react-table';
|
||||
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
@@ -29,12 +29,17 @@ import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||
import TableMenu from './cuesheet-table-menu/TableMenu';
|
||||
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
|
||||
import {
|
||||
CuesheetColumnDef,
|
||||
CuesheetTable as CuesheetTableInstance,
|
||||
cuesheetTableFeatures,
|
||||
} from './cuesheetTable.features';
|
||||
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
||||
|
||||
import style from './CuesheetTable.module.scss';
|
||||
|
||||
type CuesheetTableBaseProps = {
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
columns: CuesheetColumnDef[];
|
||||
cuesheetMode: AppMode;
|
||||
source: RundownSource;
|
||||
insertElement?: ReactNode;
|
||||
@@ -120,7 +125,8 @@ export default function CuesheetTable({
|
||||
const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot);
|
||||
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
|
||||
|
||||
const table = useReactTable({
|
||||
const table = useTable({
|
||||
features: cuesheetTableFeatures,
|
||||
data: flatRundown,
|
||||
columns,
|
||||
columnResizeMode: 'onChange',
|
||||
@@ -131,7 +137,6 @@ export default function CuesheetTable({
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
meta,
|
||||
});
|
||||
|
||||
@@ -195,7 +200,7 @@ export default function CuesheetTable({
|
||||
return colSizes;
|
||||
// eslint-disable-next-line react-compiler/react-compiler -- unfortunately this is what we need
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
|
||||
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
|
||||
}, [columns, table.state.columnResizing, table.state.columnSizing]);
|
||||
|
||||
const allLeafColumns = table.getAllLeafColumns();
|
||||
const { rows } = table.getRowModel();
|
||||
@@ -214,9 +219,7 @@ export default function CuesheetTable({
|
||||
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
|
||||
const fixedHeaderContent = useCallback(() => {
|
||||
return table.getHeaderGroups().map((headerGroup) => {
|
||||
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
|
||||
? CuesheetHeader
|
||||
: SortableCuesheetHeader;
|
||||
const HeaderComponent = table.state.columnResizing.isResizingColumn ? CuesheetHeader : SortableCuesheetHeader;
|
||||
|
||||
// if the table is being resized, we render non-sortable headers to avoid performance issues
|
||||
return (
|
||||
@@ -279,8 +282,8 @@ interface CuesheetVirtuosoContext {
|
||||
columnSizeVars: { [key: string]: number };
|
||||
cursor: string | null;
|
||||
listeners: ReturnType<typeof useTableNav>['listeners'];
|
||||
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
|
||||
table: Table<ExtendedEntry>;
|
||||
rows: ReturnType<CuesheetTableInstance['getRowModel']>['rows'];
|
||||
table: CuesheetTableInstance;
|
||||
handleAddNew?: (type: SupportedEntry) => void;
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -1,16 +1,16 @@
|
||||
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { HeaderGroup, flexRender } from '@tanstack/react-table';
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import type { CuesheetHeaderGroup } from '../cuesheetTable.features';
|
||||
import { Draggable, SortableCell, TableCell } from './SortableCell';
|
||||
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
|
||||
interface CuesheetHeaderProps {
|
||||
headerGroup: HeaderGroup<ExtendedEntry>;
|
||||
headerGroup: CuesheetHeaderGroup;
|
||||
cuesheetMode: AppMode;
|
||||
hideIndexColumn: boolean;
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function SortableCuesheetHeader({ headerGroup, cuesheetMode, hideIndexCol
|
||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||
draggable={<Draggable header={header} />}
|
||||
>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.isPlaceholder ? null : <FlexRender header={header} />}
|
||||
</SortableCell>
|
||||
);
|
||||
})}
|
||||
@@ -85,7 +85,7 @@ export function CuesheetHeader({ headerGroup, cuesheetMode, hideIndexColumn }: C
|
||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||
draggable={<Draggable header={header} />}
|
||||
>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.isPlaceholder ? null : <FlexRender header={header} />}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { EntryId, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
||||
|
||||
import style from './EventRow.module.scss';
|
||||
|
||||
@@ -25,7 +25,7 @@ interface EventRowProps {
|
||||
skip: boolean;
|
||||
parent: EntryId | null;
|
||||
rowIndex: number;
|
||||
table: Table<ExtendedEntry<OntimeEntry>>;
|
||||
table: CuesheetTable;
|
||||
injectedStyles?: CSSProperties;
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ function EventRow({
|
||||
data-testid={`cuesheet-cell-${cell.column.id}`}
|
||||
data-column-id={cell.column.id}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
<FlexRender cell={cell} />
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { CSSProperties, memo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
||||
|
||||
import style from './GroupRow.module.scss';
|
||||
|
||||
@@ -15,7 +15,7 @@ interface GroupRowProps {
|
||||
colour: string;
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
table: Table<ExtendedEntry>;
|
||||
table: CuesheetTable;
|
||||
injectedStyles?: CSSProperties;
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ function GroupRow({
|
||||
}}
|
||||
role='cell'
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
<FlexRender cell={cell} />
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
||||
|
||||
import style from './MilestoneRow.module.scss';
|
||||
|
||||
@@ -20,7 +20,7 @@ interface MilestoneRowProps {
|
||||
colour: string;
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
table: Table<ExtendedEntry>;
|
||||
table: CuesheetTable;
|
||||
injectedStyles?: CSSProperties;
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
@@ -102,7 +102,7 @@ function MilestoneRow({
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{canRender && <FlexRender cell={cell} />}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
+2
-3
@@ -1,9 +1,8 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Header } from '@tanstack/react-table';
|
||||
import { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import type { CuesheetHeaderCell } from '../cuesheetTable.features';
|
||||
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
|
||||
@@ -48,7 +47,7 @@ export function TableCell({ colSpan, injectedStyles, children, draggable }: Sort
|
||||
}
|
||||
|
||||
interface DraggableProps {
|
||||
header: Header<ExtendedEntry, unknown>;
|
||||
header: CuesheetHeaderCell;
|
||||
}
|
||||
|
||||
export function Draggable({ header }: DraggableProps) {
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
.timerOverrideCell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 1.5rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.timerOverrideIndicator {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: $active-indicator;
|
||||
font-size: 1rem;
|
||||
}
|
||||
+51
-18
@@ -1,13 +1,23 @@
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
|
||||
import {
|
||||
CustomFields,
|
||||
TimeStrategy,
|
||||
TimerType,
|
||||
URLPreset,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
} from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { useCallback } from 'react';
|
||||
import { IoTimerOutline } from 'react-icons/io5';
|
||||
|
||||
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
||||
import type { CuesheetCellContext, CuesheetColumnDef } from '../cuesheetTable.features';
|
||||
import DurationInput from './DurationInput';
|
||||
import EditableImage from './EditableImage';
|
||||
import FlagCell from './FlagCell';
|
||||
@@ -17,11 +27,13 @@ import MutedText from './MutedText';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
|
||||
import style from './cuesheetColsFactory.module.scss';
|
||||
|
||||
function getColumnLabel(column: CuesheetCellContext['column']): string {
|
||||
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
|
||||
}
|
||||
|
||||
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -60,7 +72,7 @@ function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry,
|
||||
);
|
||||
}
|
||||
|
||||
function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeEnd({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -100,7 +112,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, un
|
||||
);
|
||||
}
|
||||
|
||||
function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -131,7 +143,7 @@ function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntr
|
||||
);
|
||||
}
|
||||
|
||||
function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeMultiLineField({ row, column, table }: CuesheetCellContext) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
@@ -160,7 +172,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
|
||||
);
|
||||
}
|
||||
|
||||
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
function LazyImage({ row, column, table }: CuesheetCellContext) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||
@@ -178,7 +190,7 @@ function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>)
|
||||
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
|
||||
}
|
||||
|
||||
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
@@ -193,21 +205,42 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
|
||||
}
|
||||
|
||||
const canWrite = column.columnDef.meta?.canWrite;
|
||||
if (!canWrite) {
|
||||
return <GhostedText>{initialValue}</GhostedText>;
|
||||
}
|
||||
|
||||
return (
|
||||
const content = canWrite ? (
|
||||
<SingleLineCell
|
||||
initialValue={initialValue as string}
|
||||
fieldId={column.id}
|
||||
fieldLabel={getColumnLabel(column)}
|
||||
handleUpdate={update}
|
||||
/>
|
||||
) : (
|
||||
<GhostedText>{initialValue}</GhostedText>
|
||||
);
|
||||
|
||||
if (column.id !== 'title') {
|
||||
return content;
|
||||
}
|
||||
|
||||
const isGroupOverride = isOntimeGroup(row.original) && row.original.useGroupTimer;
|
||||
const isEventOverride = isOntimeEvent(row.original) && row.original.groupUsesTimer;
|
||||
if (!isGroupOverride && !isEventOverride) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const timerType = isOntimeGroup(row.original) ? row.original.timerType : row.original.groupTimerType;
|
||||
const direction = timerType === TimerType.CountUp ? 'count up' : 'count down';
|
||||
const tooltip = isGroupOverride ? `Group timer (${direction})` : `Timer display controlled by group (${direction})`;
|
||||
|
||||
return (
|
||||
<div className={style.timerOverrideCell}>
|
||||
{content}
|
||||
<Tooltip text={tooltip} render={<span className={style.timerOverrideIndicator} />}>
|
||||
<IoTimerOutline />
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeFlagField({ row }: CuesheetCellContext) {
|
||||
const event = row.original;
|
||||
if (!isOntimeEvent(event) || !event.flag) {
|
||||
return null;
|
||||
@@ -215,7 +248,7 @@ function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
|
||||
return <FlagCell />;
|
||||
}
|
||||
|
||||
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeCustomField({ row, column, table }: CuesheetCellContext) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||
@@ -255,8 +288,8 @@ export function makeCuesheetColumns(
|
||||
customFields: CustomFields,
|
||||
cuesheetMode: AppMode,
|
||||
preset: URLPreset | undefined,
|
||||
): ColumnDef<ExtendedEntry>[] {
|
||||
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
|
||||
): CuesheetColumnDef[] {
|
||||
const columnsDef: CuesheetColumnDef[] = [];
|
||||
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
|
||||
|
||||
if (canRead('flag')) {
|
||||
|
||||
+4
-5
@@ -2,7 +2,6 @@ import { Popover } from '@base-ui/react/popover';
|
||||
import { Toggle } from '@base-ui/react/toggle';
|
||||
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
||||
import { Toolbar } from '@base-ui/react/toolbar';
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
import { ReactNode } from 'react';
|
||||
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
|
||||
|
||||
@@ -10,9 +9,9 @@ import Button from '../../../../common/components/buttons/Button';
|
||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||
import type { CuesheetColumn } from '../cuesheetTable.features';
|
||||
import CuesheetShareModal from './CuesheetShareModal';
|
||||
|
||||
import style from './CuesheetTableSettings.module.scss';
|
||||
@@ -37,7 +36,7 @@ type TableModeControls = {
|
||||
};
|
||||
|
||||
interface CuesheetTableHeaderToolbarProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
columns: CuesheetColumn[];
|
||||
optionsStore: TableHeaderOptionsStore;
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
@@ -111,7 +110,7 @@ interface ViewSettingsProps {
|
||||
}
|
||||
|
||||
interface ColumnSettingsProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
columns: CuesheetColumn[];
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
@@ -203,7 +202,7 @@ function ColumnSettings({
|
||||
|
||||
return (
|
||||
<Editor.Label key={`${column.id}-${visible}`} className={style.option}>
|
||||
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} />
|
||||
<Checkbox defaultChecked={visible} onCheckedChange={(checked) => column.toggleVisibility(checked)} />
|
||||
{columnHeader as ReactNode}
|
||||
</Editor.Label>
|
||||
);
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&[data-background-rundown='true'] {
|
||||
background-color: rgba($ontime-color, 0.5);
|
||||
background-color: $bg-active;
|
||||
|
||||
.apart::before {
|
||||
content: 'BACKGROUND EDIT';
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
columnOrderingFeature,
|
||||
columnResizingFeature,
|
||||
columnSizingFeature,
|
||||
columnVisibilityFeature,
|
||||
metaHelper,
|
||||
tableFeatures,
|
||||
} from '@tanstack/react-table';
|
||||
import type { CellContext, Column, ColumnDef, Header, HeaderGroup, Table } from '@tanstack/react-table';
|
||||
import type { TimeField } from 'ontime-types';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import type { AppMode } from '../../../ontimeConfig';
|
||||
|
||||
/**
|
||||
* Custom data we pass to the table
|
||||
* - `handleUpdate` callback to update the entry when the user edits a cell
|
||||
* - `handleUpdateTimer` callback to update the timer for a specific event
|
||||
* - `options-showDelayedTimes` whether to show or hide delayed times
|
||||
* - `options-hideTableSeconds` whether to hide seconds in the table
|
||||
* - `options-hideIndexColumn` whether to hide the index column
|
||||
* - `options-cuesheetMode` run or edit mode
|
||||
*/
|
||||
export interface CuesheetTableMeta {
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
|
||||
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
|
||||
options: {
|
||||
showDelayedTimes: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
cuesheetMode: AppMode;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata specific for each column
|
||||
* - `canWrite` whether the user can write to this column
|
||||
* - `colour` background colour associated with a custom field
|
||||
*/
|
||||
export interface CuesheetColumnMeta {
|
||||
canWrite: boolean;
|
||||
colour?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Features registered in the cuesheet and rundown tables.
|
||||
* In v9 an API only exists once its feature is registered, so this list is the
|
||||
* source of truth for what the table can do:
|
||||
* - `columnOrderingFeature`: user reorders columns by dragging the headers
|
||||
* - `columnVisibilityFeature`: user toggles columns in the table settings
|
||||
* - `columnSizingFeature`: column widths, exposed to CSS as custom properties
|
||||
* - `columnResizingFeature`: the drag handle in the header (requires sizing)
|
||||
*
|
||||
* The `tableMeta` / `columnMeta` slots replace the v8 global module augmentation:
|
||||
* they scope our meta types to this table instead of every table in the app.
|
||||
*/
|
||||
export const cuesheetTableFeatures = tableFeatures({
|
||||
columnOrderingFeature,
|
||||
columnVisibilityFeature,
|
||||
columnSizingFeature,
|
||||
columnResizingFeature,
|
||||
tableMeta: metaHelper<CuesheetTableMeta>(),
|
||||
columnMeta: metaHelper<CuesheetColumnMeta>(),
|
||||
});
|
||||
|
||||
export type CuesheetFeatures = typeof cuesheetTableFeatures;
|
||||
|
||||
/** Convenience aliases so consumers do not need to repeat the feature generic */
|
||||
export type CuesheetColumnDef = ColumnDef<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetTable = Table<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetCellContext = CellContext<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetHeaderGroup = HeaderGroup<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetHeaderCell = Header<CuesheetFeatures, ExtendedEntry, unknown>;
|
||||
export type CuesheetColumn = Column<CuesheetFeatures, ExtendedEntry, unknown>;
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { ColumnDef, ColumnSizingState, Updater } from '@tanstack/react-table';
|
||||
import { ColumnSizingState, Updater } from '@tanstack/react-table';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { debounce } from '../../../common/utils/debounce';
|
||||
import { makeStageKey } from '../../../common/utils/localStorage';
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import type { CuesheetColumnDef } from './cuesheetTable.features';
|
||||
|
||||
type TableRoot = 'editor' | 'cuesheet';
|
||||
|
||||
@@ -38,7 +38,7 @@ export function useColumnSizes(tableRoot: TableRoot = 'cuesheet') {
|
||||
};
|
||||
}
|
||||
|
||||
export function useColumnOrder(columns: ColumnDef<ExtendedEntry>[], tableRoot: TableRoot = 'cuesheet') {
|
||||
export function useColumnOrder(columns: CuesheetColumnDef[], tableRoot: TableRoot = 'cuesheet') {
|
||||
const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]);
|
||||
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
.error {
|
||||
padding-inline: 0.5rem;
|
||||
font-size: 1rem;
|
||||
height: 3rem;
|
||||
// rows grow when a match is shown from a note or custom field
|
||||
min-height: 3rem;
|
||||
padding-block: 0.35rem;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.entry[data-selected='true'] {
|
||||
@@ -18,21 +21,47 @@
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.more {
|
||||
padding-inline: 0.5rem;
|
||||
padding-block: 0.75rem;
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $label-gray;
|
||||
border-top: 1px solid $gray-1000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
.data {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'index cue'
|
||||
'index title';
|
||||
'index title'
|
||||
'index match';
|
||||
column-gap: 1rem;
|
||||
grid-template-rows: min-content 1fr;
|
||||
min-width: 0;
|
||||
|
||||
.index {
|
||||
grid-area: index;
|
||||
background-color: var(--color, $gray-1000);
|
||||
// background and text colour come from getAccessibleColour, which keeps the
|
||||
// number legible whatever colour the user gave the entry
|
||||
border-radius: 2px;
|
||||
padding-block: 0.25rem;
|
||||
width: 3.5rem;
|
||||
@@ -42,14 +71,33 @@
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cue {
|
||||
grid-area: cue;
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $label-gray;
|
||||
max-height: 1em;
|
||||
min-height: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.match {
|
||||
grid-area: match;
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.matchLabel {
|
||||
color: $ui-white;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +111,14 @@
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.filterHint {
|
||||
.count {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.em {
|
||||
color: $ui-white;
|
||||
margin-inline: 0.25rem;
|
||||
.go {
|
||||
white-space: nowrap;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.hints {
|
||||
@@ -98,7 +147,7 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filterHint {
|
||||
.count {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useDebouncedCallback } from '@mantine/hooks';
|
||||
import { SupportedEntry } from 'ontime-types';
|
||||
import { KeyboardEvent, useState } from 'react';
|
||||
import { MaybeString } from 'ontime-types';
|
||||
import { KeyboardEvent, useDeferredValue, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import ToggleButton from '../../../common/components/buttons/ToggleButton';
|
||||
import Input from '../../../common/components/input/input/Input';
|
||||
import Kbd from '../../../common/components/kbd/Kbd';
|
||||
import Modal from '../../../common/components/modal/Modal';
|
||||
import useFinder from './useFinder';
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import useFinder, { FinderResult } from './useFinder';
|
||||
|
||||
import style from './Finder.module.scss';
|
||||
|
||||
@@ -15,46 +16,76 @@ interface FinderProps {
|
||||
}
|
||||
|
||||
export default function Finder({ isOpen, onClose }: FinderProps) {
|
||||
const { find, select, results, error } = useFinder();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<MaybeString>(null);
|
||||
const [selectedId, setSelectedId] = useState<MaybeString>(null);
|
||||
|
||||
const debouncedFind = useDebouncedCallback(find, 100);
|
||||
/**
|
||||
* Keeps typing responsive while the list re-renders.
|
||||
* The search itself is cheap, rendering the results is what costs.
|
||||
*/
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const { select, results, error, total, filters, appliedFilter } = useFinder(deferredSearch, filter);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const activeRef = useRef<HTMLLIElement>(null);
|
||||
|
||||
/**
|
||||
* We track the selection by ID so that it survives the result list changing under us:
|
||||
* an entry that no longer exists falls back to the first result instead of dangling past the end
|
||||
*/
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
results.findIndex((entry) => entry.id === selectedId),
|
||||
);
|
||||
const activeEntry = results.at(activeIndex);
|
||||
|
||||
/** keep the highlighted entry in view while navigating with the keyboard */
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeEntry?.id]);
|
||||
|
||||
const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
// pressing the search shortcut again selects the query, ready to be replaced
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
|
||||
event.preventDefault();
|
||||
inputRef.current?.select();
|
||||
return;
|
||||
}
|
||||
|
||||
// all operations need results
|
||||
if (results.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
setSelected((prev) => (prev + 1) % results.length);
|
||||
setSelectedId(results[(activeIndex + 1) % results.length].id);
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
setSelected((prev) => (prev - 1 + results.length) % results.length);
|
||||
setSelectedId(results[(activeIndex - 1 + results.length) % results.length].id);
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
submit();
|
||||
submit(activeEntry);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const selectedEvent = results[selected];
|
||||
select(selectedEvent);
|
||||
const submit = (entry: FinderResult | undefined) => {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
select(entry);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleMouseMoveEvent = (event: React.MouseEvent<HTMLUListElement>) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const li = target.closest('li');
|
||||
if (li) {
|
||||
const index = Number(li.dataset.index);
|
||||
if (!isNaN(index)) {
|
||||
setSelected(index);
|
||||
}
|
||||
}
|
||||
/** Scopes the search to a single field, or back to all fields when tapped again */
|
||||
const handleFilter = (filterKey: string) => {
|
||||
setFilter((previous) => (previous === filterKey ? null : filterKey));
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const hiddenResults = total - results.length;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title=''
|
||||
@@ -63,35 +94,68 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
||||
showBackdrop
|
||||
bodyElements={
|
||||
<div onKeyDown={navigate}>
|
||||
<Input height='large' fluid onChange={debouncedFind} placeholder='Search...' />
|
||||
<ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
height='large'
|
||||
fluid
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder='Search...'
|
||||
/>
|
||||
<div className={style.filters} data-testid='finder-filters'>
|
||||
<span className={style.filterLabel}>Filter by</span>
|
||||
{filters.map((option) => (
|
||||
<ToggleButton
|
||||
key={option.key}
|
||||
pressed={appliedFilter === option.key}
|
||||
size='small'
|
||||
onClick={() => handleFilter(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</div>
|
||||
<ul className={style.scrollContainer}>
|
||||
{error && <li className={style.error}>{error}</li>}
|
||||
{results.length === 0 && <li className={style.empty}>No results</li>}
|
||||
{results.length > 0 &&
|
||||
results.map((entry, index) => {
|
||||
const isSelected = selected === index;
|
||||
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
|
||||
const displayCue = 'cue' in entry ? entry.cue : '';
|
||||
{!error && results.length === 0 && <li className={style.empty}>No results</li>}
|
||||
{results.map((entry) => {
|
||||
const isSelected = activeEntry?.id === entry.id;
|
||||
// the title and cue are already on the row, a match anywhere else needs showing
|
||||
const showMatch = entry.match !== null && entry.match.key !== 'title' && entry.match.key !== 'cue';
|
||||
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
className={style.entry}
|
||||
data-selected={isSelected}
|
||||
data-index={index}
|
||||
onClick={submit}
|
||||
>
|
||||
<div className={style.data}>
|
||||
<div className={style.index} style={{ '--color': entry.colour }}>
|
||||
{displayIndex}
|
||||
</div>
|
||||
<div className={style.cue}>{displayCue}</div>
|
||||
<div className={style.title}>{entry.title}</div>
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
ref={isSelected ? activeRef : undefined}
|
||||
className={style.entry}
|
||||
data-testid='finder-result'
|
||||
data-selected={isSelected}
|
||||
onClick={() => submit(entry)}
|
||||
onPointerEnter={() => setSelectedId(entry.id)}
|
||||
>
|
||||
<div className={style.data}>
|
||||
<div className={style.index} style={getAccessibleColour(entry.colour)}>
|
||||
{entry.eventIndex ?? '-'}
|
||||
</div>
|
||||
{isSelected && <span>Go ⏎</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<div className={style.cue}>{entry.cue}</div>
|
||||
<div className={style.title}>{entry.title}</div>
|
||||
{showMatch && (
|
||||
<div className={style.match} data-testid='finder-result-match'>
|
||||
<span className={style.matchLabel}>{entry.match?.label}</span>
|
||||
{entry.match?.excerpt}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && <span className={style.go}>Go ⏎</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{hiddenResults > 0 && (
|
||||
<li className={style.more} data-testid='finder-more'>
|
||||
{hiddenResults} more {hiddenResults === 1 ? 'result' : 'results'} — keep typing to narrow the search
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@@ -112,10 +176,11 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.filterHint}>
|
||||
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
|
||||
<span className={style.em}>title</span>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className={style.count} data-testid='finder-count'>
|
||||
{hiddenResults > 0 ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { CustomFields, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { parseQuery, searchByIndex, searchByText } from './useFinder';
|
||||
|
||||
function makeEvent(id: string, overrides: Partial<OntimeEvent> = {}): OntimeEvent {
|
||||
return {
|
||||
type: SupportedEntry.Event,
|
||||
id,
|
||||
cue: '',
|
||||
title: '',
|
||||
note: '',
|
||||
colour: '#000000',
|
||||
custom: {},
|
||||
parent: null,
|
||||
...overrides,
|
||||
} as OntimeEvent;
|
||||
}
|
||||
|
||||
function makeGroup(id: string, overrides: Partial<OntimeGroup> = {}): OntimeGroup {
|
||||
return {
|
||||
type: SupportedEntry.Group,
|
||||
id,
|
||||
title: '',
|
||||
note: '',
|
||||
colour: '#000000',
|
||||
custom: {},
|
||||
...overrides,
|
||||
} as OntimeGroup;
|
||||
}
|
||||
|
||||
function makeMilestone(id: string, overrides: Partial<OntimeMilestone> = {}): OntimeMilestone {
|
||||
return {
|
||||
type: SupportedEntry.Milestone,
|
||||
id,
|
||||
cue: '',
|
||||
title: '',
|
||||
note: '',
|
||||
colour: '#000000',
|
||||
custom: {},
|
||||
parent: null,
|
||||
...overrides,
|
||||
} as OntimeMilestone;
|
||||
}
|
||||
|
||||
function makeDelay(id: string): OntimeDelay {
|
||||
return { type: SupportedEntry.Delay, id, duration: 1000, parent: null };
|
||||
}
|
||||
|
||||
describe('parseQuery()', () => {
|
||||
const filters = [
|
||||
{ key: 'cue', label: 'Cue' },
|
||||
{ key: 'Camera_Notes', label: 'Camera Notes' },
|
||||
];
|
||||
|
||||
it.each([
|
||||
['cue 12', { filterKey: 'cue', searchString: '12' }],
|
||||
['cue:12', { filterKey: 'cue', searchString: '12' }],
|
||||
['camera_notes:wide', { filterKey: 'Camera_Notes', searchString: 'wide' }],
|
||||
])('parses the field prefix in %s', (searchValue, expected) => {
|
||||
expect(parseQuery(searchValue, filters)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('keeps an unprefixed query as a search across all fields', () => {
|
||||
expect(parseQuery('zebrafish', filters)).toStrictEqual({ filterKey: null, searchString: 'zebrafish' });
|
||||
});
|
||||
|
||||
it('recognises a filter before any search text has been entered', () => {
|
||||
expect(parseQuery('cue', filters)).toStrictEqual({ filterKey: 'cue', searchString: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchByText()', () => {
|
||||
const customFields: CustomFields = {
|
||||
Camera_Notes: { type: 'text', label: 'Camera Notes', colour: '#000000' },
|
||||
Slide: { type: 'image', label: 'Slide', colour: '#000000' },
|
||||
};
|
||||
|
||||
it('searches cue, title, note, and text custom fields in rundown order', () => {
|
||||
const data = [
|
||||
makeMilestone('milestone', { cue: 'needle' }),
|
||||
makeGroup('group', { title: 'needle' }),
|
||||
makeEvent('note', { note: 'find the needle here' }),
|
||||
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
|
||||
];
|
||||
|
||||
const outcome = searchByText(data, customFields, null, 'needle');
|
||||
|
||||
expect(outcome.results.map(({ id, match }) => ({ id, field: match?.key }))).toStrictEqual([
|
||||
{ id: 'milestone', field: 'cue' },
|
||||
{ id: 'group', field: 'title' },
|
||||
{ id: 'note', field: 'note' },
|
||||
{ id: 'custom', field: 'Camera_Notes' },
|
||||
]);
|
||||
expect(outcome.total).toBe(4);
|
||||
});
|
||||
|
||||
it('searches only the selected field', () => {
|
||||
const data = [
|
||||
makeEvent('title', { title: 'needle' }),
|
||||
makeEvent('note', { note: 'needle' }),
|
||||
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
|
||||
];
|
||||
|
||||
const outcome = searchByText(data, customFields, 'note', 'needle');
|
||||
|
||||
expect(outcome.results.map((result) => result.id)).toStrictEqual(['note']);
|
||||
expect(outcome.total).toBe(1);
|
||||
});
|
||||
|
||||
it('reports the first matching field so the result can explain why it matched', () => {
|
||||
const data = [makeEvent('event', { cue: 'NEEDLE', title: 'another needle' })];
|
||||
|
||||
const outcome = searchByText(data, customFields, null, 'needle');
|
||||
|
||||
expect(outcome.results[0].match).toStrictEqual({ key: 'cue', label: 'Cue', excerpt: 'NEEDLE' });
|
||||
});
|
||||
|
||||
it('does not search image custom fields', () => {
|
||||
const data = [makeEvent('image-only', { custom: { Slide: 'needle' } })];
|
||||
|
||||
expect(searchByText(data, customFields, null, 'needle')).toStrictEqual({ results: [], error: null, total: 0 });
|
||||
});
|
||||
|
||||
it('reports the full match count while limiting rendered results', () => {
|
||||
const data = Array.from({ length: 51 }, (_, index) => makeEvent(String(index), { title: 'needle' }));
|
||||
|
||||
const outcome = searchByText(data, customFields, null, 'needle');
|
||||
|
||||
expect(outcome.results).toHaveLength(50);
|
||||
expect(outcome.total).toBe(51);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchByIndex()', () => {
|
||||
it('counts only events while preserving the flat rundown position', () => {
|
||||
const data = [
|
||||
makeGroup('group'),
|
||||
makeDelay('delay'),
|
||||
makeEvent('first'),
|
||||
makeMilestone('milestone'),
|
||||
makeEvent('second'),
|
||||
];
|
||||
|
||||
const outcome = searchByIndex(data, '2');
|
||||
|
||||
expect(outcome.results).toHaveLength(1);
|
||||
expect(outcome.results[0]).toMatchObject({ id: 'second', index: 4, eventIndex: 2 });
|
||||
expect(outcome.total).toBe(1);
|
||||
});
|
||||
|
||||
it.each(['0', 'not-a-number'])('rejects invalid index %s', (index) => {
|
||||
expect(searchByIndex([makeEvent('event')], index)).toStrictEqual({
|
||||
results: [],
|
||||
error: 'Invalid index',
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no result when the event index is beyond the rundown', () => {
|
||||
expect(searchByIndex([makeEvent('event')], '2')).toStrictEqual({ results: [], error: null, total: 0 });
|
||||
});
|
||||
});
|
||||
@@ -1,239 +1,259 @@
|
||||
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
CustomFields,
|
||||
EntryId,
|
||||
MaybeNumber,
|
||||
MaybeString,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
} from 'ontime-types';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
||||
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
|
||||
|
||||
const maxResults = 12;
|
||||
/** How many results we render, the total number of matches is reported separately */
|
||||
const maxResults = 50;
|
||||
/** Notes can hold a whole script, we only show enough to explain the match */
|
||||
const excerptPadding = 40;
|
||||
|
||||
type FilterableGroup = {
|
||||
type: SupportedEntry.Group;
|
||||
id: EntryId;
|
||||
index: number;
|
||||
title: string;
|
||||
colour: string;
|
||||
};
|
||||
const indexFilter = 'index';
|
||||
|
||||
type FilterableEvent = {
|
||||
type: SupportedEntry.Event;
|
||||
/** Everything except delays, which carry no text to search */
|
||||
type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone;
|
||||
|
||||
type FinderFilter = { key: string; label: string };
|
||||
|
||||
/**
|
||||
* Offered to the user as filter badges. Index is a positional lookup rather than a
|
||||
* text field, so it is handled separately from the fields a search runs over.
|
||||
*/
|
||||
const staticFilters: FinderFilter[] = [
|
||||
{ key: indexFilter, label: 'Index' },
|
||||
{ key: 'cue', label: 'Cue' },
|
||||
{ key: 'title', label: 'Title' },
|
||||
{ key: 'note', label: 'Note' },
|
||||
];
|
||||
|
||||
/** Why an entry matched, so the UI can show the user */
|
||||
type FinderMatch = { key: string; label: string; excerpt: string };
|
||||
|
||||
export type FinderResult = {
|
||||
id: EntryId;
|
||||
/** position in the flat rundown, which is how the rundown reveals an entry */
|
||||
index: number;
|
||||
eventIndex: number;
|
||||
/** 1-based position among events, null for groups and milestones */
|
||||
eventIndex: MaybeNumber;
|
||||
title: string;
|
||||
/** groups have no cue */
|
||||
cue: string;
|
||||
colour: string;
|
||||
parent: MaybeString;
|
||||
/** absent when the entry was found by index rather than by matching text */
|
||||
match: FinderMatch | null;
|
||||
};
|
||||
|
||||
type FilterableMilestone = {
|
||||
type: SupportedEntry.Milestone;
|
||||
id: EntryId;
|
||||
index: number;
|
||||
title: string;
|
||||
cue: string;
|
||||
colour: string;
|
||||
parent: MaybeString;
|
||||
};
|
||||
type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: number };
|
||||
|
||||
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
|
||||
const noResults: SearchOutcome = { results: [], error: null, total: 0 };
|
||||
|
||||
export default function useFinder() {
|
||||
/** Groups are the only searchable entry with neither a cue nor a parent */
|
||||
function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber, match: FinderMatch | null) {
|
||||
return {
|
||||
id: entry.id,
|
||||
index,
|
||||
eventIndex,
|
||||
title: entry.title,
|
||||
cue: 'cue' in entry ? entry.cue : '',
|
||||
colour: entry.colour,
|
||||
parent: 'parent' in entry ? entry.parent : null,
|
||||
match,
|
||||
} satisfies FinderResult;
|
||||
}
|
||||
|
||||
/** Shows enough of a long value for the user to see why it matched */
|
||||
function makeExcerpt(value: string, matchIndex: number, searchLength: number): string {
|
||||
const start = Math.max(0, matchIndex - excerptPadding);
|
||||
const end = Math.min(value.length, matchIndex + searchLength + excerptPadding);
|
||||
return `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first field of an entry to contain the search string, if any.
|
||||
* Fields are tried in the order we prefer to report a match.
|
||||
*/
|
||||
function findMatch(
|
||||
entry: SearchableEntry,
|
||||
customFields: CustomFields,
|
||||
filterKey: MaybeString,
|
||||
searchString: string,
|
||||
): FinderMatch | null {
|
||||
function check(key: string, label: string, value: string): FinderMatch | null {
|
||||
if (!value || (filterKey !== null && key !== filterKey)) {
|
||||
return null;
|
||||
}
|
||||
const matchIndex = value.toLowerCase().indexOf(searchString);
|
||||
if (matchIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
return { key, label, excerpt: makeExcerpt(value, matchIndex, searchString.length) };
|
||||
}
|
||||
|
||||
// groups have no cue, the rest is common to every searchable entry
|
||||
const fromCue = 'cue' in entry ? check('cue', 'Cue', entry.cue) : null;
|
||||
const match = fromCue ?? check('title', 'Title', entry.title) ?? check('note', 'Note', entry.note);
|
||||
if (match !== null) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// custom fields are named by the project, so these can only be reached generically
|
||||
for (const [key, value] of Object.entries(entry.custom)) {
|
||||
const definition = customFields[key];
|
||||
if (definition?.type !== 'text') {
|
||||
continue;
|
||||
}
|
||||
const custom = check(key, definition.label || key, value);
|
||||
if (custom) return custom;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the raw search value into an optional field filter and the text to look for.
|
||||
* Both `cue 12` and `cue:12` are accepted so that typing agrees with the filter badges.
|
||||
*/
|
||||
export function parseQuery(searchValue: string, filters: FinderFilter[]) {
|
||||
for (const filter of filters) {
|
||||
// the search value is already lowercased, custom field keys are not
|
||||
const prefix = filter.key.toLowerCase();
|
||||
if (searchValue === prefix) {
|
||||
return { filterKey: filter.key, searchString: '' };
|
||||
}
|
||||
if (searchValue.startsWith(`${prefix} `) || searchValue.startsWith(`${prefix}:`)) {
|
||||
return { filterKey: filter.key, searchString: searchValue.slice(prefix.length + 1).trim() };
|
||||
}
|
||||
}
|
||||
return { filterKey: null, searchString: searchValue };
|
||||
}
|
||||
|
||||
/** Finds the single event at a 1-based position in the rundown */
|
||||
export function searchByIndex(data: OntimeEntry[], indexString: string): SearchOutcome {
|
||||
const target = Number(indexString);
|
||||
if (isNaN(target) || target < 1) {
|
||||
return { ...noResults, error: 'Invalid index' };
|
||||
}
|
||||
|
||||
let eventIndex = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const entry = data[i];
|
||||
if (!isOntimeEvent(entry)) {
|
||||
continue;
|
||||
}
|
||||
eventIndex++;
|
||||
if (eventIndex === target) {
|
||||
return { results: [toResult(entry, i, eventIndex, null)], error: null, total: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
return noResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches entries on a single field when one is selected, otherwise on every text field.
|
||||
* Results keep rundown order, which keeps them predictable during a show.
|
||||
*/
|
||||
export function searchByText(
|
||||
data: OntimeEntry[],
|
||||
customFields: CustomFields,
|
||||
filterKey: MaybeString,
|
||||
searchString: string,
|
||||
): SearchOutcome {
|
||||
const results: FinderResult[] = [];
|
||||
let total = 0;
|
||||
// indexes exposed to the UI are 1-based
|
||||
let eventIndex = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const entry = data[i];
|
||||
if (isOntimeDelay(entry)) {
|
||||
continue;
|
||||
}
|
||||
const isEvent = isOntimeEvent(entry);
|
||||
if (isEvent) {
|
||||
eventIndex++;
|
||||
}
|
||||
|
||||
const match = findMatch(entry, customFields, filterKey, searchString);
|
||||
if (match === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
total++;
|
||||
if (results.length < maxResults) {
|
||||
results.push(toResult(entry, i, isEvent ? eventIndex : null, match));
|
||||
}
|
||||
}
|
||||
|
||||
return { results, error: null, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param searchValue - the text the user is looking for
|
||||
* @param activeFilter - a field selected from the filter badges, if any
|
||||
*/
|
||||
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
|
||||
const { data, rundownId } = useFlatRundown();
|
||||
const [results, setResults] = useState<FilterableEntry[]>([]);
|
||||
const [error, setError] = useState<MaybeString>(null);
|
||||
const lastSearchString = useRef('');
|
||||
const { data: customFields } = useCustomFields();
|
||||
|
||||
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
|
||||
|
||||
/** Filters the rundown to a given evaluation */
|
||||
const find = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!data || data.length === 0) {
|
||||
setError('No data');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
/** The filters offered to the user: the fixed fields plus whatever the project defines */
|
||||
const filters = useMemo<FinderFilter[]>(() => {
|
||||
const customFilters = Object.entries(customFields)
|
||||
.filter(([_key, field]) => field.type === 'text')
|
||||
.map(([key, field]) => ({ key, label: field.label || key }));
|
||||
return [...staticFilters, ...customFilters];
|
||||
}, [customFields]);
|
||||
|
||||
if (event.target.value === '') {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
const { results, error, total, appliedFilter } = useMemo(() => {
|
||||
if (data.length === 0) {
|
||||
return { ...noResults, error: 'No data', appliedFilter: activeFilter };
|
||||
}
|
||||
|
||||
const searchValue = event.target.value.toLowerCase();
|
||||
lastSearchString.current = searchValue;
|
||||
const normalised = searchValue.trim().toLowerCase();
|
||||
if (normalised === '') {
|
||||
return { ...noResults, appliedFilter: activeFilter };
|
||||
}
|
||||
|
||||
if (searchValue.startsWith('index ')) {
|
||||
const searchString = searchValue.slice('index '.length).trim();
|
||||
const { results, error } = searchByIndex(searchString);
|
||||
setResults(results);
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* If a badge is selected it scopes the search; otherwise, keyword prefixes in the input
|
||||
* (e.g. "cue:" / "title:") are parsed and the matching badge is highlighted.
|
||||
*/
|
||||
const { filterKey, searchString } = activeFilter
|
||||
? { filterKey: activeFilter, searchString: normalised }
|
||||
: parseQuery(normalised, filters);
|
||||
|
||||
if (searchValue.startsWith('cue ')) {
|
||||
const searchString = searchValue.slice('cue '.length).trim();
|
||||
const { results, error } = searchByCue(searchString);
|
||||
setResults(results);
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue;
|
||||
const { results, error } = searchByTitle(searchString);
|
||||
setResults(results);
|
||||
setError(error);
|
||||
|
||||
/** Returns a single item with a matching index */
|
||||
function searchByIndex(searchString: string) {
|
||||
const searchIndex = Number(searchString);
|
||||
if (isNaN(searchIndex) || searchIndex < 1) {
|
||||
return { results: [], error: 'Invalid index' };
|
||||
}
|
||||
|
||||
if (searchIndex > data.length) {
|
||||
return { results: [], error: null };
|
||||
}
|
||||
|
||||
// indexes exposed to the UI are 1-based
|
||||
let eventIndex = 1;
|
||||
const results: FilterableEvent[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const event = data[i];
|
||||
if (isOntimeEvent(event)) {
|
||||
if (eventIndex === searchIndex) {
|
||||
results.push({
|
||||
type: SupportedEntry.Event,
|
||||
id: event.id,
|
||||
index: i,
|
||||
eventIndex,
|
||||
title: event.title,
|
||||
cue: event.cue,
|
||||
colour: event.colour,
|
||||
parent: event.parent,
|
||||
} satisfies FilterableEvent);
|
||||
break;
|
||||
}
|
||||
eventIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return { results, error: null };
|
||||
}
|
||||
|
||||
/** Returns maxResults of OntimeEvents that match the cue field */
|
||||
function searchByCue(searchString: string) {
|
||||
// indexes exposed to the UI are 1-based
|
||||
let eventIndex = 1;
|
||||
// limit amount of results we show
|
||||
let remaining = maxResults;
|
||||
const results: FilterableEvent[] = [];
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
const event = data[i];
|
||||
if (isOntimeEvent(event)) {
|
||||
if (event.cue.toLowerCase().includes(searchString)) {
|
||||
remaining--;
|
||||
results.push({
|
||||
type: SupportedEntry.Event,
|
||||
id: event.id,
|
||||
index: i,
|
||||
eventIndex,
|
||||
title: event.title,
|
||||
cue: event.cue,
|
||||
colour: event.colour,
|
||||
parent: event.parent,
|
||||
} satisfies FilterableEvent);
|
||||
}
|
||||
eventIndex++;
|
||||
}
|
||||
}
|
||||
return { results, error: null };
|
||||
}
|
||||
|
||||
/** Returns maxResults of OntimeEvents that match the title field*/
|
||||
function searchByTitle(searchString: string) {
|
||||
// indexes exposed to the UI are 1-based
|
||||
let eventIndex = 1;
|
||||
// limit amount of results we show
|
||||
let remaining = maxResults;
|
||||
const results: FilterableEntry[] = [];
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const entry = data[i];
|
||||
if (isOntimeEvent(entry)) {
|
||||
if (entry.title.toLowerCase().includes(searchString)) {
|
||||
remaining--;
|
||||
results.push({
|
||||
type: SupportedEntry.Event,
|
||||
id: entry.id,
|
||||
index: i,
|
||||
eventIndex,
|
||||
title: entry.title,
|
||||
cue: entry.cue,
|
||||
colour: entry.colour,
|
||||
parent: entry.parent,
|
||||
} satisfies FilterableEvent);
|
||||
}
|
||||
eventIndex++;
|
||||
} else if (isOntimeGroup(entry)) {
|
||||
if (entry.title.toLowerCase().includes(searchString)) {
|
||||
remaining--;
|
||||
results.push({
|
||||
type: SupportedEntry.Group,
|
||||
id: entry.id,
|
||||
index: i,
|
||||
title: entry.title,
|
||||
colour: entry.colour,
|
||||
} satisfies FilterableGroup);
|
||||
}
|
||||
} else if (isOntimeMilestone(entry)) {
|
||||
if (entry.title.toLowerCase().includes(searchString)) {
|
||||
remaining--;
|
||||
results.push({
|
||||
type: SupportedEntry.Milestone,
|
||||
id: entry.id,
|
||||
index: i,
|
||||
title: entry.title,
|
||||
cue: entry.cue,
|
||||
colour: entry.colour,
|
||||
parent: entry.parent,
|
||||
} satisfies FilterableMilestone);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { results, error: null };
|
||||
}
|
||||
},
|
||||
[data],
|
||||
);
|
||||
if (filterKey === indexFilter) {
|
||||
return { ...searchByIndex(data, searchString), appliedFilter: filterKey };
|
||||
}
|
||||
if (searchString === '') {
|
||||
// a filter is selected, but there is nothing to match on yet
|
||||
return { ...noResults, appliedFilter: filterKey };
|
||||
}
|
||||
return { ...searchByText(data, customFields, filterKey, searchString), appliedFilter: filterKey };
|
||||
}, [data, customFields, filters, searchValue, activeFilter]);
|
||||
|
||||
const select = useCallback(
|
||||
(selectedEvent: FilterableEntry) => {
|
||||
selectAndRevealEntry({
|
||||
id: selectedEvent.id,
|
||||
index: selectedEvent.index,
|
||||
parent: 'parent' in selectedEvent ? selectedEvent.parent : null,
|
||||
});
|
||||
(result: FinderResult) => {
|
||||
selectAndRevealEntry({ id: result.id, index: result.index, parent: result.parent });
|
||||
},
|
||||
[selectAndRevealEntry],
|
||||
);
|
||||
|
||||
/** clear results when source data changes */
|
||||
useEffect(() => {
|
||||
setResults([]);
|
||||
setError(null);
|
||||
// fake a submit event to re-run the search
|
||||
if (lastSearchString.current) {
|
||||
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
}, [data, find]);
|
||||
|
||||
return { find, select, results, error };
|
||||
return { select, results, error, total, filters, appliedFilter };
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.timer-source {
|
||||
color: $viewer-label-color;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.timer {
|
||||
opacity: 1;
|
||||
font-family: $viewer-font-family;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cx } from '../../../common/utils/styleUtils';
|
||||
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
|
||||
import {
|
||||
getEstimatedFontSize,
|
||||
getEventTimerSecondary,
|
||||
getIsPlaying,
|
||||
getSecondaryDisplay,
|
||||
getShowMessage,
|
||||
@@ -23,7 +24,18 @@ interface PipTimerProps {
|
||||
}
|
||||
|
||||
export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
|
||||
const {
|
||||
eventNow,
|
||||
message,
|
||||
time,
|
||||
eventTimer,
|
||||
clock,
|
||||
timerTypeNow,
|
||||
eventTimerType,
|
||||
countToEndNow,
|
||||
usesGroupTimer,
|
||||
auxTimer,
|
||||
} = useTimerSocket();
|
||||
|
||||
// gather modifiers
|
||||
const showOverlay = getShowMessage(message.timer);
|
||||
@@ -59,7 +71,9 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
return null;
|
||||
})();
|
||||
|
||||
const secondaryContent = getSecondaryDisplay(message, currentAux, 'min', false, true, false);
|
||||
const secondaryContent = usesGroupTimer
|
||||
? getEventTimerSecondary(eventTimer, eventTimerType, clock, 'min', false, true)
|
||||
: getSecondaryDisplay(message, currentAux, 'min', false, true, false);
|
||||
|
||||
// gather presentation styles
|
||||
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
|
||||
@@ -77,6 +91,7 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
</div>
|
||||
|
||||
<div className='timer-container'>
|
||||
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
|
||||
<div
|
||||
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
||||
style={{ fontSize: `${timerFontSize}vw` }}
|
||||
@@ -96,11 +111,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||
now={time.current}
|
||||
complete={totalTime}
|
||||
eventId={eventNow?.id}
|
||||
eventId={usesGroupTimer ? undefined : eventNow?.id}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={eventNow?.timeWarning}
|
||||
warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={eventNow?.timeDanger}
|
||||
danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
hideOvertime={!showFinished}
|
||||
/>
|
||||
|
||||
@@ -89,6 +89,14 @@
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.timer-source {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
font-size: $timer-label-size;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.end-message {
|
||||
text-align: center;
|
||||
font-size: 11.5vw;
|
||||
@@ -195,6 +203,24 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sound-prompt {
|
||||
position: absolute;
|
||||
bottom: $view-block-padding;
|
||||
left: $view-inline-padding;
|
||||
padding: 0.5em 0.75em;
|
||||
border-radius: $element-border-radius;
|
||||
background-color: $viewer-card-bg-color;
|
||||
color: $viewer-secondary-color;
|
||||
font-size: $timer-label-size;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
transition: opacity $viewer-transition-time;
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================== LOGO ===================*/
|
||||
.logo {
|
||||
position: absolute;
|
||||
|
||||
@@ -8,6 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard';
|
||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
|
||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||
import { useTimerSocket } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
@@ -21,6 +22,7 @@ import { getTimerOptions, useTimerOptions } from './timer.options';
|
||||
import {
|
||||
getCardData,
|
||||
getEstimatedFontSize,
|
||||
getEventTimerSecondary,
|
||||
getIsPlaying,
|
||||
getSecondaryDisplay,
|
||||
getShowClock,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
getTotalTime,
|
||||
} from './timer.utils';
|
||||
import { TimerData, useTimerData } from './useTimerData';
|
||||
import { useTimerSound } from './useTimerSound';
|
||||
|
||||
import './Timer.scss';
|
||||
|
||||
@@ -50,7 +53,19 @@ export default function TimerLoader() {
|
||||
}
|
||||
|
||||
function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) {
|
||||
const { eventNext, eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
|
||||
const {
|
||||
eventNext,
|
||||
eventNow,
|
||||
message,
|
||||
time,
|
||||
eventTimer,
|
||||
clock,
|
||||
timerTypeNow,
|
||||
eventTimerType,
|
||||
countToEndNow,
|
||||
usesGroupTimer,
|
||||
auxTimer,
|
||||
} = useTimerSocket();
|
||||
const {
|
||||
hideClock,
|
||||
hideCards,
|
||||
@@ -66,6 +81,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
freezeOvertime,
|
||||
freezeMessage,
|
||||
hidePhase,
|
||||
endSound,
|
||||
font,
|
||||
keyColour,
|
||||
timerColour,
|
||||
@@ -75,6 +91,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const localisedMinutes = getLocalizedString('common.minutes');
|
||||
|
||||
const showSoundPrompt = useTimerSound(eventTimer.phase, endSound);
|
||||
|
||||
// gather modifiers
|
||||
const viewTimerType = timerType ?? timerTypeNow;
|
||||
const showOverlay = getShowMessage(message.timer);
|
||||
@@ -123,14 +141,18 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
return null;
|
||||
})();
|
||||
|
||||
const secondaryContent = getSecondaryDisplay(
|
||||
message,
|
||||
currentAux,
|
||||
localisedMinutes,
|
||||
hideTimerSeconds,
|
||||
removeLeadingZeros,
|
||||
hideSecondary,
|
||||
);
|
||||
const secondaryContent =
|
||||
usesGroupTimer && !hideSecondary
|
||||
? getEventTimerSecondary(
|
||||
eventTimer,
|
||||
eventTimerType,
|
||||
clock,
|
||||
localisedMinutes,
|
||||
hideTimerSeconds,
|
||||
removeLeadingZeros,
|
||||
timeformat,
|
||||
)
|
||||
: getSecondaryDisplay(message, currentAux, localisedMinutes, hideTimerSeconds, removeLeadingZeros, hideSecondary);
|
||||
|
||||
// gather presentation styles
|
||||
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
|
||||
@@ -156,6 +178,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
|
||||
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
|
||||
|
||||
{showSoundPrompt && <SoundPermissionPrompt />}
|
||||
|
||||
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
|
||||
|
||||
{!hideMessage && (
|
||||
@@ -169,6 +193,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
{showClock && <TimerAutoTickingClock clockFormat={timeformat} />}
|
||||
|
||||
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
|
||||
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
|
||||
{showEndMessage ? (
|
||||
<FitText mode='multi' min={64} max={256} className='end-message'>
|
||||
{freezeMessage}
|
||||
@@ -195,11 +220,11 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||
now={time.current}
|
||||
complete={totalTime}
|
||||
eventId={eventNow?.id}
|
||||
eventId={usesGroupTimer ? undefined : eventNow?.id}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={eventNow?.timeWarning}
|
||||
warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
|
||||
warningColor={viewSettings.warningColor}
|
||||
danger={eventNow?.timeDanger}
|
||||
danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
hideOvertime={!showFinished}
|
||||
/>
|
||||
@@ -227,3 +252,13 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SoundPermissionPrompt() {
|
||||
const isUserActive = useFadeOutOnInactivity(true);
|
||||
|
||||
return (
|
||||
<div className={cx(['sound-prompt', !isUserActive && 'sound-prompt--hidden'])} aria-live='polite'>
|
||||
Interact with the page (click/tap or press any key) to enable sound
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { TimerPhase, TimerType } from 'ontime-types';
|
||||
|
||||
import { getEventTimerSecondary, shouldPlayEndSound } from '../timer.utils';
|
||||
|
||||
describe('getEventTimerSecondary()', () => {
|
||||
it('formats the event countdown as a labelled secondary value', () => {
|
||||
expect(
|
||||
getEventTimerSecondary({ current: 65_000, elapsed: 5_000 }, TimerType.CountDown, 0, 'min', false, false),
|
||||
).toBe('Event timer 00:01:05');
|
||||
});
|
||||
|
||||
it('preserves the event count-up display', () => {
|
||||
expect(getEventTimerSecondary({ current: 55_000, elapsed: 5_000 }, TimerType.CountUp, 0, 'min', false, false)).toBe(
|
||||
'Event timer 00:00:05',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to remaining time when the event timer is hidden', () => {
|
||||
expect(getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.None, 0, 'min', false, false)).toBe(
|
||||
'Event timer 00:00:05',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows event progress instead of wall-clock time for clock events', () => {
|
||||
expect(
|
||||
getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.Clock, 12_000, 'min', false, false),
|
||||
).toBe('Event timer 00:00:05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldPlayEndSound()', () => {
|
||||
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
|
||||
'sounds when a running timer goes into overtime from %s',
|
||||
(previousPhase) => {
|
||||
expect(shouldPlayEndSound(previousPhase, TimerPhase.Overtime)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('stays silent on the first phase we see, a client could be joining mid-overtime', () => {
|
||||
expect(shouldPlayEndSound(null, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent when the phase was reset, a reload during overtime starts from none', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.None, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent for a roll timer waiting to start', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.Pending, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('sounds once, not on every update while in overtime', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.Overtime)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent on phases which are not the end of the timer', () => {
|
||||
expect(shouldPlayEndSound(TimerPhase.Default, TimerPhase.Warning)).toBe(false);
|
||||
expect(shouldPlayEndSound(TimerPhase.Warning, TimerPhase.Danger)).toBe(false);
|
||||
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.None)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { ViewOption } from '../../common/components/view-params-editor/viewParam
|
||||
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
||||
import { PresetContext } from '../../common/context/PresetContext';
|
||||
import { isStringBoolean, makeColourString } from '../common/viewUtils';
|
||||
import { endSoundOptions, isEndSound, type EndSound } from './timer.sound';
|
||||
|
||||
// manually match the properties of TimerType excluding the None
|
||||
const timerDisplayOptions: SelectOption[] = [
|
||||
@@ -76,6 +77,15 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'endSound',
|
||||
title: 'Play sound on timer end',
|
||||
description:
|
||||
'Plays a sound in this screen when the timer reaches zero. The screen must be interacted with once before it can play',
|
||||
type: 'option',
|
||||
values: endSoundOptions,
|
||||
defaultValue: 'none',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -169,7 +179,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
||||
{
|
||||
id: 'timerColour',
|
||||
title: 'Timer Colour',
|
||||
description: 'Timer colour. Default: #f6f6f6',
|
||||
description: 'Timer colour. Default: #F6F6F6',
|
||||
type: 'colour',
|
||||
defaultValue: 'f6f6f6',
|
||||
},
|
||||
@@ -193,6 +203,7 @@ type TimerOptions = {
|
||||
freezeOvertime: boolean;
|
||||
freezeMessage: string;
|
||||
hidePhase: boolean;
|
||||
endSound: EndSound;
|
||||
font?: string;
|
||||
keyColour?: string;
|
||||
timerColour?: string;
|
||||
@@ -208,6 +219,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
||||
|
||||
// Get timerType from either source
|
||||
const timerType = validateTimerType(getValue('timerType'), TimerType.None);
|
||||
const endSoundValue = getValue('endSound');
|
||||
|
||||
return {
|
||||
hideClock: isStringBoolean(getValue('hideClock')),
|
||||
@@ -227,6 +239,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
||||
freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
|
||||
freezeMessage: getValue('freezeMessage') ?? '',
|
||||
hidePhase: isStringBoolean(getValue('hidePhase')),
|
||||
endSound: isEndSound(endSoundValue) ? endSoundValue : 'none',
|
||||
|
||||
font: getValue('font') ?? undefined,
|
||||
keyColour: makeColourString(getValue('keyColour')),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import arpeggio from '../../assets/sounds/arpeggio.wav';
|
||||
import bell from '../../assets/sounds/bell.wav';
|
||||
import chime from '../../assets/sounds/chime.wav';
|
||||
import type { SelectOption } from '../../common/components/select/Select';
|
||||
|
||||
// synthesised tones bundled with the app, no external source or licence to track
|
||||
export const endSoundSources = { chime, bell, arpeggio } as const;
|
||||
|
||||
export type EndSound = 'none' | keyof typeof endSoundSources;
|
||||
|
||||
export const endSoundOptions: SelectOption[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'chime', label: 'Chime' },
|
||||
{ value: 'bell', label: 'Bell' },
|
||||
{ value: 'arpeggio', label: 'Arpeggio' },
|
||||
];
|
||||
|
||||
export function isEndSound(value: string | null): value is EndSound {
|
||||
return value === 'none' || value === 'chime' || value === 'bell' || value === 'arpeggio';
|
||||
}
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
RundownEntries,
|
||||
TimerMessage,
|
||||
TimerPhase,
|
||||
TimerState,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
import { isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import { getFormattedTimer, getPropertyValue } from '../common/viewUtils';
|
||||
import { getFormattedTimer, getPropertyValue, getTimerByType } from '../common/viewUtils';
|
||||
|
||||
/**
|
||||
* Whether a message should be shown
|
||||
@@ -144,6 +145,25 @@ export function getSecondaryDisplay(
|
||||
return;
|
||||
}
|
||||
|
||||
export function getEventTimerSecondary(
|
||||
timer: Pick<TimerState, 'current' | 'elapsed'>,
|
||||
timerType: TimerType,
|
||||
clock: number,
|
||||
localisedMinutes: string,
|
||||
removeSeconds: boolean,
|
||||
removeLeadingZero: boolean,
|
||||
clockFormat?: string | null,
|
||||
): string {
|
||||
const effectiveType = timerType === TimerType.CountUp ? TimerType.CountUp : TimerType.CountDown;
|
||||
const value = getTimerByType(false, effectiveType, clock, timer);
|
||||
const display = getFormattedTimer(value, effectiveType, localisedMinutes, {
|
||||
removeSeconds,
|
||||
removeLeadingZero,
|
||||
clockFormat,
|
||||
});
|
||||
return `Event timer ${display}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What should we be showing in the cards?
|
||||
*/
|
||||
@@ -189,3 +209,18 @@ export function getCardData(
|
||||
nextSecondary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the end of timer sound should play for a given phase transition
|
||||
* We only sound the transition into overtime from a phase that was already counting,
|
||||
* which keeps a client that connects or reloads mid-overtime silent
|
||||
*/
|
||||
export function shouldPlayEndSound(previousPhase: TimerPhase | null, phase: TimerPhase): boolean {
|
||||
if (phase !== TimerPhase.Overtime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
previousPhase === TimerPhase.Default || previousPhase === TimerPhase.Warning || previousPhase === TimerPhase.Danger
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { TimerPhase } from 'ontime-types';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { endSoundSources, type EndSound } from './timer.sound';
|
||||
import { shouldPlayEndSound } from './timer.utils';
|
||||
|
||||
/**
|
||||
* Plays a sound when the timer reaches its end
|
||||
*
|
||||
* Browsers reject playback until the document has been interacted with, and that permission
|
||||
* is lost on every page load. Since a timer screen is typically left unattended, we prime the
|
||||
* audio element on the first interaction and let the view prompt for one if it never comes.
|
||||
* Safari grants the permission per element, so priming has to call play() on this element from
|
||||
* inside the event handler, it is not enough to know that an interaction happened.
|
||||
*/
|
||||
export function useTimerSound(phase: TimerPhase, sound: EndSound): boolean {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const previousPhaseRef = useRef<TimerPhase | null>(null);
|
||||
const [isArmed, setIsArmed] = useState(false);
|
||||
|
||||
const enabled = sound !== 'none';
|
||||
|
||||
// Create and clean up the audio element; changing sounds requires re-arming it in Safari.
|
||||
useEffect(() => {
|
||||
setIsArmed(false);
|
||||
|
||||
if (sound === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = new Audio(endSoundSources[sound]);
|
||||
audioRef.current = audio;
|
||||
|
||||
return () => {
|
||||
audio.pause();
|
||||
audioRef.current = null;
|
||||
};
|
||||
}, [sound]);
|
||||
|
||||
// Listen for user interaction until muted playback succeeds and arms the selected audio element.
|
||||
useEffect(() => {
|
||||
if (!enabled || isArmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const prime = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasMuted = audio.muted;
|
||||
audio.muted = true;
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
if (audioRef.current !== audio) {
|
||||
return;
|
||||
}
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
setIsArmed(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// playback is still blocked, a later interaction will try again
|
||||
})
|
||||
.finally(() => {
|
||||
audio.muted = wasMuted;
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', prime, { capture: true, signal: controller.signal });
|
||||
document.addEventListener('keydown', prime, { capture: true, signal: controller.signal });
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [enabled, isArmed]);
|
||||
|
||||
// Track phase transitions and play only when a running timer enters overtime.
|
||||
useEffect(() => {
|
||||
const previousPhase = previousPhaseRef.current;
|
||||
previousPhaseRef.current = phase;
|
||||
|
||||
if (!enabled || !shouldPlayEndSound(previousPhase, phase)) {
|
||||
return;
|
||||
}
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch(() => {
|
||||
// the screen has not been interacted with, the view shows a prompt for it
|
||||
});
|
||||
}, [enabled, phase]);
|
||||
|
||||
return enabled && !isArmed;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.12.0",
|
||||
"version": "4.13.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -14,8 +14,8 @@
|
||||
"main": "src/main.js",
|
||||
"devDependencies": {
|
||||
"electron": "38.2.1",
|
||||
"electron-builder": "26.9.1",
|
||||
"wait-on": "^7.2.0"
|
||||
"electron-builder": "26.15.3",
|
||||
"wait-on": "^9.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/resolver",
|
||||
"version": "4.12.0",
|
||||
"version": "4.13.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
"types": "./dist/main.d.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"parser"
|
||||
],
|
||||
"author": "",
|
||||
"license": "AGPL-3.0-only",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"ontime-types": "workspace:^4.2.1",
|
||||
"tsdown": "^0.22.4",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "4.12.0",
|
||||
"version": "4.13.0",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
@@ -10,7 +10,7 @@
|
||||
"cookie": "1.0.2",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.6",
|
||||
"dotenv": "^16.0.1",
|
||||
"dotenv": "^17.0.0",
|
||||
"express": "5.2.1",
|
||||
"express-static-gzip": "3.0.1",
|
||||
"express-validator": "7.3.2",
|
||||
@@ -22,8 +22,7 @@
|
||||
"osc-min": "2.1.2",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"ws": "^8.18.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "4.4.3"
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
@@ -32,11 +31,11 @@
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "^8.5.10",
|
||||
"esbuild": "^0.24.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"server-timing": "^3.3.3",
|
||||
"ts-essentials": "catalog:",
|
||||
"tsx": "^4.19.2",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
|
||||
@@ -417,7 +417,7 @@ export function migrateRundown(
|
||||
timeEnd: null,
|
||||
duration: 0,
|
||||
isFirstLinked: false,
|
||||
});
|
||||
} as unknown as OntimeEntry);
|
||||
} else if (entry.type === 'delay') {
|
||||
append({ id: entry.id, type: SupportedEntry.Delay, duration: entry.duration, parent });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry } from 'ontime-types';
|
||||
import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry, TimerType } from 'ontime-types';
|
||||
|
||||
import { makeNewRundown } from '../../../models/dataModel.js';
|
||||
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
|
||||
@@ -276,7 +276,13 @@ describe('parseRundown()', () => {
|
||||
expect(parsedRundown.order).toStrictEqual(['group']);
|
||||
expect(parsedRundown.flatOrder).toStrictEqual(['group', '1', '2']);
|
||||
expect(parsedRundown.entries).toMatchObject({
|
||||
group: { id: 'group', type: SupportedEntry.Group, entries: ['1', '2'] },
|
||||
group: {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
entries: ['1', '2'],
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
},
|
||||
'1': { id: '1', type: SupportedEntry.Event },
|
||||
'2': { id: '2', type: SupportedEntry.Milestone },
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { deepEqual } from 'fast-equals';
|
||||
import {
|
||||
EndAction,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
@@ -8,13 +10,22 @@ import {
|
||||
TimerType,
|
||||
Trigger,
|
||||
} from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils';
|
||||
import {
|
||||
MILLIS_PER_HOUR,
|
||||
MILLIS_PER_MINUTE,
|
||||
createDelay,
|
||||
createEvent,
|
||||
createGroup,
|
||||
createMilestone,
|
||||
} from 'ontime-utils';
|
||||
import { assertType } from 'vitest';
|
||||
|
||||
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||
import { parseRundown } from '../rundown.parser.js';
|
||||
import {
|
||||
calculateDayOffset,
|
||||
cloneEntryData,
|
||||
createGroupPatch,
|
||||
deleteById,
|
||||
doesInvalidateMetadata,
|
||||
getIntegerAndFraction,
|
||||
@@ -26,6 +37,25 @@ import {
|
||||
} from '../rundown.utils.js';
|
||||
|
||||
describe('test event validator', () => {
|
||||
it('creates groups with the shared timer disabled by default', () => {
|
||||
expect(createGroup({ id: 'group' })).toMatchObject({
|
||||
useGroupTimer: false,
|
||||
timerType: TimerType.CountDown,
|
||||
});
|
||||
});
|
||||
|
||||
it('limits group timers to count down and count up', () => {
|
||||
expect(createGroup({ timerType: TimerType.CountUp }).timerType).toBe(TimerType.CountUp);
|
||||
expect(createGroup({ timerType: TimerType.Clock }).timerType).toBe(TimerType.CountDown);
|
||||
});
|
||||
|
||||
it('rejects non-boolean group timer updates', () => {
|
||||
const group = createGroup({ useGroupTimer: false });
|
||||
const updated = createGroupPatch(group, { useGroupTimer: 'true' as never });
|
||||
|
||||
expect(updated.useGroupTimer).toBe(false);
|
||||
});
|
||||
|
||||
it('validates a good object', () => {
|
||||
const event = {
|
||||
title: 'test',
|
||||
@@ -715,3 +745,68 @@ describe('eventDurationMatchGroupTarget()', () => {
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneEntryData()', () => {
|
||||
const trigger: Trigger = {
|
||||
id: 'trigger-1',
|
||||
title: 'Go on air',
|
||||
trigger: TimerLifeCycle.onStart,
|
||||
automationId: 'automation-1',
|
||||
};
|
||||
|
||||
// the real factories, so these are complete entries exactly as the rundown holds them
|
||||
const entries: [string, OntimeEntry][] = [
|
||||
['event', createEvent({ custom: { sponsor: 'a value' }, triggers: [trigger] }, 'cue-1') as OntimeEvent],
|
||||
['group', createGroup({ id: 'group-1', entries: ['a', 'b'], custom: { sponsor: 'a value' } })],
|
||||
['milestone', createMilestone({ id: 'milestone-1', custom: { sponsor: 'a value' } })],
|
||||
['delay', createDelay({ id: 'delay-1', duration: 10 })],
|
||||
];
|
||||
|
||||
/**
|
||||
* Fails if any nested object or array in the clone is the same reference as the source,
|
||||
* so a field added later that needs a copy of its own is caught here without the clone
|
||||
* having to enumerate fields.
|
||||
*/
|
||||
function expectNoSharedReferences(clone: unknown, source: unknown, path: string) {
|
||||
if (typeof source !== 'object' || source === null) return;
|
||||
expect(clone, `${path} is shared with the source`).not.toBe(source);
|
||||
const cloneRecord = clone as Record<string, unknown>;
|
||||
const sourceRecord = source as Record<string, unknown>;
|
||||
for (const key of Object.keys(sourceRecord)) {
|
||||
expectNoSharedReferences(cloneRecord[key], sourceRecord[key], `${path}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** the two halves of the structuredClone contract: same value, no shared references */
|
||||
it.each(entries)('clones a %s to the same value structuredClone would produce', (_type, entry) => {
|
||||
expect(cloneEntryData(entry)).toStrictEqual(structuredClone(entry));
|
||||
});
|
||||
|
||||
it.each(entries)('shares no nested object or array with the source %s', (_type, entry) => {
|
||||
expectNoSharedReferences(cloneEntryData(entry), entry, 'entry');
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression: normalising an absent container to an empty one makes deepEqual report a
|
||||
* change on every comparison, which would have the runtime re-broadcast and re-save the
|
||||
* restore point on every tick. See PR #2178.
|
||||
*/
|
||||
it.each([
|
||||
['event', makeOntimeEvent({ id: 'partial' })],
|
||||
['group', makeOntimeGroup({ id: 'partial', entries: undefined })],
|
||||
])('gives a partial %s exactly the keys structuredClone would, so it stays deep-equal', (_type, entry) => {
|
||||
const clone = cloneEntryData(entry);
|
||||
// asserting on keys, not values: `toBeUndefined()` cannot tell an absent key from an own
|
||||
// key holding undefined, and it is key presence that decides the deepEqual below
|
||||
expect(Object.keys(clone).sort()).toEqual(Object.keys(structuredClone(entry)).sort());
|
||||
// this is the comparison runtime.service.ts uses to decide whether to re-broadcast an
|
||||
// entry; if the clone gains a key, every tick looks like a change
|
||||
expect(deepEqual(clone, entry)).toBe(true);
|
||||
});
|
||||
|
||||
it('throws on an entry type it does not know how to clone', () => {
|
||||
expect(() => cloneEntryData({ id: 'x', type: 'unknown' } as unknown as OntimeEvent)).toThrow(
|
||||
'Unsupported entry type for cloning',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
isPlayableEvent,
|
||||
} from 'ontime-types';
|
||||
import { addToRundown, createGroup, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils';
|
||||
import type { DeepReadonly } from 'ts-essentials';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { consoleError } from '../../utils/console.js';
|
||||
@@ -34,6 +35,7 @@ import { ProcessedRundownMetadata, makeRundownMetadata } from './rundown.parser.
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
import {
|
||||
applyPatchToEntry,
|
||||
cloneRundown,
|
||||
cloneSimpleRundownEntry,
|
||||
deleteById,
|
||||
doesInvalidateMetadata,
|
||||
@@ -43,9 +45,14 @@ import {
|
||||
} from './rundown.utils.js';
|
||||
|
||||
/**
|
||||
* The currently loaded rundown in cache
|
||||
* The currently loaded rundown in cache.
|
||||
*
|
||||
* Reassigned - never mutated in place - when a different rundown is loaded: the persistence
|
||||
* layer stores this object by reference, so repurposing it for another rundown would rewrite
|
||||
* the previously loaded rundown's stored record. Mutating it in place while it represents the
|
||||
* same rundown (ie. from commit) is intended, and is what keeps the stored record current.
|
||||
*/
|
||||
const cachedRundown: Rundown = {
|
||||
let cachedRundown: Rundown = {
|
||||
id: '',
|
||||
title: '',
|
||||
order: [],
|
||||
@@ -79,9 +86,16 @@ export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetada
|
||||
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
|
||||
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
|
||||
|
||||
type Transaction = {
|
||||
/**
|
||||
* @param R the type callers see for `rundown` - a plain, mutable `Rundown` when the
|
||||
* transaction was opened with `mutableRundown: true`, otherwise a `DeepReadonly<Rundown>`
|
||||
* so that accidentally mutating an entry (or an order array) on a non-mutable transaction
|
||||
* - which would silently corrupt the live cache without going through commit() - is a
|
||||
* compile-time error instead of a runtime bug.
|
||||
*/
|
||||
type Transaction<R> = {
|
||||
customFields: CustomFields;
|
||||
rundown: Rundown;
|
||||
rundown: R;
|
||||
|
||||
commit: (shouldProcess?: boolean) => Promise<{
|
||||
rundown: Readonly<Rundown>;
|
||||
@@ -102,11 +116,17 @@ type TransactionOptions = {
|
||||
rundownId?: string;
|
||||
};
|
||||
|
||||
export function createTransaction(options: TransactionOptions): Transaction {
|
||||
export function createTransaction(options: TransactionOptions & { mutableRundown: true }): Transaction<Rundown>;
|
||||
export function createTransaction(
|
||||
options: TransactionOptions & { mutableRundown?: false },
|
||||
): Transaction<DeepReadonly<Rundown>>;
|
||||
export function createTransaction(
|
||||
options: TransactionOptions,
|
||||
): Transaction<Rundown> | Transaction<DeepReadonly<Rundown>> {
|
||||
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 rundown = options.mutableRundown ? cloneRundown(sourceRundown) : sourceRundown;
|
||||
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
|
||||
|
||||
/**
|
||||
@@ -707,21 +727,25 @@ export const customFieldMutation = {
|
||||
* Expose function to add an initial rundown to the system
|
||||
*/
|
||||
export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) {
|
||||
const rundown = structuredClone(initialRundown);
|
||||
const rundown = cloneRundown(initialRundown);
|
||||
const customFields = structuredClone(initialCustomFields);
|
||||
const processedData = processRundown(rundown, customFields, { mutate: true });
|
||||
|
||||
// update the cache values
|
||||
cachedRundown.id = rundown.id;
|
||||
cachedRundown.title = rundown.title;
|
||||
projectCustomFields = customFields;
|
||||
|
||||
// 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;
|
||||
cachedRundown.revision = rundown.revision;
|
||||
|
||||
// a fresh object, so that the record already stored for a previously loaded rundown keeps
|
||||
// pointing at that rundown's data - see the note on cachedRundown
|
||||
cachedRundown = {
|
||||
id: rundown.id,
|
||||
title: rundown.title,
|
||||
entries,
|
||||
order,
|
||||
flatOrder: metadata.flatEntryOrder,
|
||||
revision: rundown.revision,
|
||||
};
|
||||
rundownMetadata = metadata;
|
||||
|
||||
// defer writing to the database
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { makeNewRundown } from '../../models/dataModel.js';
|
||||
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||
import { RundownMetadata } from './rundown.types.js';
|
||||
import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js';
|
||||
import { calculateDayOffset, cleanupCustomFields, cloneEntryData } from './rundown.utils.js';
|
||||
|
||||
/**
|
||||
* Parse a rundowns object along with the project custom fields
|
||||
@@ -234,7 +234,7 @@ export function makeRundownMetadata(customFields: CustomFields, options?: { muta
|
||||
};
|
||||
|
||||
function process<T extends OntimeEntry>(entry: T, childOfGroup: EntryId | null): T {
|
||||
return processEntry(rundownMeta, customFields, mutate ? entry : structuredClone(entry), childOfGroup);
|
||||
return processEntry(rundownMeta, customFields, mutate ? entry : cloneEntryData(entry), childOfGroup);
|
||||
}
|
||||
|
||||
function getMetadata(): ProcessedRundownMetadata {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { parseRundown } from './rundown.parser.js';
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
import {
|
||||
cloneRundown,
|
||||
generateEvent,
|
||||
getFirstInsertId,
|
||||
getIntegerAndFraction,
|
||||
@@ -626,7 +627,7 @@ export async function editCustomField(
|
||||
// ... reassign references in the background rundowns
|
||||
for (const rundownId of Object.keys(projectRundowns)) {
|
||||
if (rundownId !== rundown.id) {
|
||||
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||
const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
|
||||
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
|
||||
await updateBackgroundRundown(rundownId, backgroundRundown);
|
||||
}
|
||||
@@ -666,7 +667,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
|
||||
// remove references in the background rundowns
|
||||
for (const rundownId of Object.keys(projectRundowns)) {
|
||||
if (rundownId !== rundown.id) {
|
||||
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||
const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
|
||||
customFieldMutation.removeUsages(backgroundRundown, key);
|
||||
await updateBackgroundRundown(rundownId, backgroundRundown);
|
||||
}
|
||||
@@ -818,7 +819,7 @@ export async function renameRundown(id: string, title: string) {
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(id);
|
||||
|
||||
await dataProvider.setRundown(id, { ...rundown, title });
|
||||
await dataProvider.setRundown(id, { ...rundown, title, revision: rundown.revision + 1 });
|
||||
|
||||
/**
|
||||
* If we are modifying the loaded rundown we re-init it
|
||||
@@ -846,7 +847,7 @@ export async function duplicateExistingRundown(id: string) {
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(id);
|
||||
|
||||
const duplicatedRundown: Rundown = structuredClone(rundown);
|
||||
const duplicatedRundown: Rundown = cloneRundown(rundown);
|
||||
duplicatedRundown.id = generateId();
|
||||
duplicatedRundown.title = `Copy of ${rundown.title}`;
|
||||
duplicatedRundown.revision = 0;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
makeString,
|
||||
maxDuration,
|
||||
validateEndAction,
|
||||
validateGroupTimerType,
|
||||
validateTimerType,
|
||||
validateTimes,
|
||||
} from 'ontime-utils';
|
||||
@@ -180,6 +181,9 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
|
||||
note: makeString(patchGroup.note, originalGroup.note),
|
||||
entries: patchGroup.entries ?? originalGroup.entries,
|
||||
targetDuration: maybeTargetDuration(),
|
||||
useGroupTimer:
|
||||
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
|
||||
timerType: validateGroupTimerType(patchGroup.timerType, originalGroup.timerType),
|
||||
colour: makeString(patchGroup.colour, originalGroup.colour),
|
||||
revision: originalGroup.revision,
|
||||
timeStart: originalGroup.timeStart,
|
||||
@@ -329,7 +333,7 @@ export function mergeRundownPreservingFields(
|
||||
const structure = isOntimeGroup(incomingEntry)
|
||||
? { entries: incomingEntry.entries }
|
||||
: { parent: incomingEntry.parent };
|
||||
entries[id] = structuredClone({ ...merged, ...structure });
|
||||
entries[id] = cloneEntryData({ ...merged, ...structure });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -353,19 +357,19 @@ export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundo
|
||||
|
||||
/** List of event properties which do not need the rundown to be regenerated */
|
||||
enum RegenerateWhitelist {
|
||||
'id', // adding it for completeness, users cannot change ID
|
||||
'type', // adding it for completeness, users cannot change ID
|
||||
'cue',
|
||||
'title',
|
||||
'note',
|
||||
'endAction',
|
||||
'timerType',
|
||||
'countToEnd',
|
||||
'colour',
|
||||
'timeWarning',
|
||||
'timeDanger',
|
||||
'custom',
|
||||
'triggers',
|
||||
id, // adding it for completeness, users cannot change ID
|
||||
type, // adding it for completeness, users cannot change ID
|
||||
cue,
|
||||
title,
|
||||
note,
|
||||
endAction,
|
||||
timerType,
|
||||
countToEnd,
|
||||
colour,
|
||||
timeWarning,
|
||||
timeDanger,
|
||||
custom,
|
||||
triggers,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,6 +503,61 @@ export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): Ont
|
||||
throw new Error(`Unsupported entry type for cloning: ${entry}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast, shape-aware clone of a single entry, preserving its identity (id, revision, etc).
|
||||
* Drop-in replacement for `structuredClone(entry)`
|
||||
*/
|
||||
export function cloneEntryData<T extends OntimeEntry>(entry: T): T {
|
||||
switch (entry.type) {
|
||||
case SupportedEntry.Event: {
|
||||
const clone: OntimeEvent = { ...entry };
|
||||
if (clone.custom) clone.custom = { ...clone.custom };
|
||||
if (clone.triggers) clone.triggers = clone.triggers.map((trigger) => ({ ...trigger }));
|
||||
return clone as T;
|
||||
}
|
||||
case SupportedEntry.Group: {
|
||||
const clone: OntimeGroup = { ...entry };
|
||||
if (clone.custom) clone.custom = { ...clone.custom };
|
||||
if (clone.entries) clone.entries = clone.entries.slice();
|
||||
return clone as T;
|
||||
}
|
||||
case SupportedEntry.Milestone: {
|
||||
const clone: OntimeMilestone = { ...entry };
|
||||
if (clone.custom) clone.custom = { ...clone.custom };
|
||||
return clone as T;
|
||||
}
|
||||
case SupportedEntry.Delay:
|
||||
return { ...entry } as T;
|
||||
default: {
|
||||
// exhaustiveness guard: a new member of `SupportedEntry` is named in the error here
|
||||
const unhandled: never = entry;
|
||||
throw new Error(`Unsupported entry type for cloning: ${(unhandled as OntimeEntry).type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast, shape-aware clone of a whole rundown.
|
||||
* Drop-in replacement for `structuredClone(rundown)`: every entry (and its nested
|
||||
* `custom` / `triggers` / `entries` containers) gets its own copy, so callers can mutate
|
||||
* the result freely without touching the source - same contract as structuredClone,
|
||||
* at a fraction of the cost since we skip the generic serialization algorithm.
|
||||
*/
|
||||
export function cloneRundown(rundown: Readonly<Rundown>): Rundown {
|
||||
const entries: RundownEntries = {};
|
||||
for (const id in rundown.entries) {
|
||||
entries[id] = cloneEntryData(rundown.entries[id]);
|
||||
}
|
||||
return {
|
||||
id: rundown.id,
|
||||
title: rundown.title,
|
||||
revision: rundown.revision,
|
||||
order: rundown.order.slice(),
|
||||
flatOrder: rundown.flatOrder.slice(),
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility for calculating if the current events should have a day offset
|
||||
* @param current the current event under test
|
||||
|
||||
@@ -464,9 +464,8 @@ export async function upload(sheetId: string, options: ImportMap) {
|
||||
sheetOrder.forEach((entryId, index) => {
|
||||
const isGroupEnd = entryId.startsWith('group-end-');
|
||||
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
|
||||
const entry = isGroupEnd
|
||||
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
|
||||
: structuredClone(rundown.entries[id]);
|
||||
// cellRequestFromEvent only reads the entry to build a cell request, no clone is needed
|
||||
const entry = isGroupEnd ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) : rundown.entries[id];
|
||||
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildToolList, defineTool, makeToolRegistry, ok } from '../mcp.registry.js';
|
||||
|
||||
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
|
||||
|
||||
const echo = vi.fn(async (args: unknown) => ok(args));
|
||||
|
||||
const tools = [
|
||||
defineTool(
|
||||
'test_echo',
|
||||
{
|
||||
description: 'Echoes its arguments',
|
||||
inputSchema: z.strictObject({
|
||||
id: z.string().describe('An id'),
|
||||
count: z.number().optional(),
|
||||
filename: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((filename) => filename?.toUpperCase()),
|
||||
}),
|
||||
annotations: READ,
|
||||
},
|
||||
echo,
|
||||
),
|
||||
defineTool(
|
||||
'test_throws',
|
||||
{
|
||||
description: 'Always fails',
|
||||
inputSchema: z.strictObject({}),
|
||||
annotations: READ,
|
||||
},
|
||||
async () => {
|
||||
throw new Error('the rundown is not loaded');
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
const dispatch = makeToolRegistry(tools);
|
||||
|
||||
describe('dispatchToolCall', () => {
|
||||
it('passes validated arguments to the handler', async () => {
|
||||
const result = await dispatch('test_echo', { id: 'entry-1', count: 2 });
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(echo).toHaveBeenCalledWith({ id: 'entry-1', count: 2 });
|
||||
});
|
||||
|
||||
it('hands the handler the transformed value, not the raw one', async () => {
|
||||
await dispatch('test_echo', { id: 'entry-1', filename: 'show.json' });
|
||||
expect(echo).toHaveBeenLastCalledWith({ id: 'entry-1', filename: 'SHOW.JSON' });
|
||||
});
|
||||
|
||||
it('rejects malformed arguments as a protocol error, without running the handler', async () => {
|
||||
echo.mockClear();
|
||||
// this is what the SDK itself does on the registerTool path, and what v2 will do
|
||||
await expect(dispatch('test_echo', { count: 'two' })).rejects.toThrow(McpError);
|
||||
await expect(dispatch('test_echo', { count: 'two' })).rejects.toMatchObject({ code: ErrorCode.InvalidParams });
|
||||
expect(echo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names every invalid field in a single message', async () => {
|
||||
await expect(dispatch('test_echo', { count: 'two', bogus: true })).rejects.toThrow(
|
||||
/Invalid arguments for test_echo:.*id.*count.*bogus/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown tool as a protocol error', async () => {
|
||||
await expect(dispatch('test_missing', {})).rejects.toMatchObject({
|
||||
code: ErrorCode.MethodNotFound,
|
||||
message: expect.stringContaining('Unknown tool: test_missing'),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Business failures are returned to the agent rather than thrown, so that it can read the
|
||||
* message and recover. Only malformed calls are protocol errors.
|
||||
*/
|
||||
it('returns failures from the services as tool errors', async () => {
|
||||
const result = await dispatch('test_throws', {});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content).toEqual([{ type: 'text', text: '{"error":"the rundown is not loaded"}' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildToolList', () => {
|
||||
it('advertises the input shape, before any transform is applied', () => {
|
||||
const [echoTool] = buildToolList(tools);
|
||||
expect(echoTool.inputSchema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'An id' },
|
||||
count: { type: 'number' },
|
||||
filename: { type: 'string' },
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the description and annotations through', () => {
|
||||
const [echoTool] = buildToolList(tools);
|
||||
expect(echoTool.description).toBe('Echoes its arguments');
|
||||
expect(echoTool.annotations).toEqual(READ);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TOOLS, TOOL_LIST } from '../mcp.tools.js';
|
||||
|
||||
describe('tool wiring', () => {
|
||||
it('advertises every declared tool', () => {
|
||||
expect(TOOL_LIST).toHaveLength(TOOLS.length);
|
||||
expect(TOOL_LIST.map((tool) => tool.name)).toEqual(TOOLS.map((tool) => tool.name));
|
||||
});
|
||||
|
||||
it('declares unique tool names', () => {
|
||||
const names = TOOLS.map((tool) => tool.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it('gives every tool a handler, a description and annotations', () => {
|
||||
const incomplete = TOOLS.filter(
|
||||
(tool) => typeof tool.handler !== 'function' || !tool.config.description || !tool.config.annotations,
|
||||
);
|
||||
expect(incomplete.map((tool) => tool.name)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generated input schemas', () => {
|
||||
it('advertises an object schema for every tool', () => {
|
||||
const notObjects = TOOL_LIST.filter((tool) => tool.inputSchema.type !== 'object');
|
||||
expect(notObjects.map((tool) => tool.name)).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Recursive schemas would generate $ref/$defs, which some MCP clients handle poorly.
|
||||
* Batch creation is deliberately modelled two levels deep to avoid them.
|
||||
*/
|
||||
it('generates self contained schemas, without $ref or $defs', () => {
|
||||
const withReferences = TOOL_LIST.filter((tool) => /\$ref|\$defs|\$schema/.test(JSON.stringify(tool.inputSchema)));
|
||||
expect(withReferences.map((tool) => tool.name)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects unknown keys in every tool', () => {
|
||||
const permissive = TOOL_LIST.filter((tool) => tool.inputSchema.additionalProperties !== false);
|
||||
expect(permissive.map((tool) => tool.name)).toEqual([]);
|
||||
});
|
||||
|
||||
it('describes required arguments', () => {
|
||||
const byName = new Map(TOOL_LIST.map((tool) => [tool.name, tool.inputSchema]));
|
||||
|
||||
expect(byName.get('ontime_update_entry')?.required).toEqual(['id']);
|
||||
expect(byName.get('ontime_delete_entries')?.required).toEqual(['ids']);
|
||||
expect(byName.get('ontime_reorder_entry')?.required).toEqual(['entryId', 'destinationId', 'order']);
|
||||
expect(byName.get('ontime_batch_update_entries')?.required).toEqual(['ids', 'data']);
|
||||
expect(byName.get('ontime_create_custom_field')?.required).toEqual(['label', 'type', 'colour']);
|
||||
expect(byName.get('ontime_rename_project')?.required).toEqual(['filename', 'newFilename']);
|
||||
// tools which take no arguments advertise an empty object
|
||||
expect(byName.get('ontime_list_rundowns')).toEqual({
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises the pre-sanitisation shape of filenames', () => {
|
||||
// the agent must be told the shape it should send, not the transformed value
|
||||
const schema = TOOL_LIST.find((tool) => tool.name === 'ontime_delete_project')?.inputSchema;
|
||||
expect(schema?.properties?.filename).toEqual({
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
description: 'Project filename to delete (with .json extension)',
|
||||
});
|
||||
});
|
||||
|
||||
it('generates a stable schema for a representative tool', () => {
|
||||
const schema = TOOL_LIST.find((tool) => tool.name === 'ontime_group_entries')?.inputSchema;
|
||||
expect(schema).toMatchInlineSnapshot(`
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"colour": {
|
||||
"description": "Hex colour (#RRGGBB) for the group — prefer the default Ontime palette from ontime://style-guide",
|
||||
"type": "string",
|
||||
},
|
||||
"custom": {
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
},
|
||||
"description": "Custom field values keyed by existing project field key",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
"ids": {
|
||||
"description": "Existing top-level entry IDs to group",
|
||||
"items": {
|
||||
"type": "string",
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array",
|
||||
},
|
||||
"note": {
|
||||
"description": "Free-text group note for production notes or references",
|
||||
"type": "string",
|
||||
},
|
||||
"rundownId": {
|
||||
"description": "Optional target rundown ID. Omit to target the currently loaded live rundown; provide an ID from ontime_list_rundowns to edit a background rundown without loading it.",
|
||||
"type": "string",
|
||||
},
|
||||
"targetDuration": {
|
||||
"description": "Planned length of the group in ms",
|
||||
"type": "number",
|
||||
},
|
||||
"title": {
|
||||
"description": "Group title shown in the rundown and views",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"ids",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { TOOLS } from '../mcp.tools.js';
|
||||
|
||||
const PROJECTS_DIR = resolve('/projects');
|
||||
|
||||
function schemaFor(name: string) {
|
||||
const tool = TOOLS.find((candidate) => candidate.name === name);
|
||||
if (!tool?.config.inputSchema) {
|
||||
throw new Error(`No input schema for ${name}`);
|
||||
}
|
||||
return tool.config.inputSchema;
|
||||
}
|
||||
|
||||
/** Parses and returns the sanitised arguments, failing the test on invalid input */
|
||||
function parse(name: string, args: Record<string, unknown>): Record<string, unknown> {
|
||||
const result = schemaFor(name).safeParse(args);
|
||||
if (!result.success) {
|
||||
throw new Error(`Expected ${name} to accept the arguments: ${issuesOf(result.error)}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function issuesOf(error: z.ZodError): string {
|
||||
return error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ');
|
||||
}
|
||||
|
||||
/** Returns the issue messages of arguments which must not be accepted */
|
||||
function reject(name: string, args: Record<string, unknown>): string {
|
||||
const result = schemaFor(name).safeParse(args);
|
||||
if (result.success) {
|
||||
throw new Error(`Expected ${name} to reject ${JSON.stringify(args)}`);
|
||||
}
|
||||
return issuesOf(result.error);
|
||||
}
|
||||
|
||||
describe('required arguments', () => {
|
||||
it('rejects an update without an entry id', () => {
|
||||
expect(reject('ontime_update_entry', { title: 'Keynote' })).toContain('id');
|
||||
});
|
||||
|
||||
it('rejects a reorder without a destination', () => {
|
||||
expect(reject('ontime_reorder_entry', { entryId: 'entry-1', order: 'after' })).toContain('destinationId');
|
||||
});
|
||||
|
||||
it('accepts a well formed update', () => {
|
||||
expect(parse('ontime_update_entry', { id: 'entry-1', title: 'Keynote', timeStart: 32400000 })).toEqual({
|
||||
id: 'entry-1',
|
||||
title: 'Keynote',
|
||||
timeStart: 32400000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('argument types', () => {
|
||||
it('rejects a stringified number where a time is expected', () => {
|
||||
expect(reject('ontime_update_entry', { id: 'entry-1', timeStart: '09:00' })).toContain('timeStart');
|
||||
});
|
||||
|
||||
it('rejects a non-array list of ids', () => {
|
||||
expect(reject('ontime_delete_entries', { ids: 'entry-1' })).toContain('ids');
|
||||
});
|
||||
|
||||
it('rejects an empty list of ids to group', () => {
|
||||
expect(reject('ontime_group_entries', { ids: [] })).toContain('Provide at least one entry ID to group.');
|
||||
});
|
||||
|
||||
it('rejects an unknown reorder position', () => {
|
||||
expect(reject('ontime_reorder_entry', { entryId: 'a', destinationId: 'b', order: 'sideways' })).toContain('order');
|
||||
});
|
||||
|
||||
it('rejects an unknown entry type', () => {
|
||||
expect(reject('ontime_create_entry', { type: 'interlude' })).toContain('type');
|
||||
});
|
||||
|
||||
it('rejects non-string custom field values', () => {
|
||||
expect(reject('ontime_update_entry', { id: 'entry-1', custom: { Camera: 2 } })).toContain('custom.Camera');
|
||||
});
|
||||
|
||||
it('accepts an insert anchor as an id or as true, but not as false', () => {
|
||||
expect(parse('ontime_create_entry', { after: 'entry-1' })).toEqual({ after: 'entry-1' });
|
||||
expect(parse('ontime_create_entry', { before: true })).toEqual({ before: true });
|
||||
// `after: false` has no meaning: the services only check whether the anchor is present
|
||||
expect(reject('ontime_create_entry', { after: false })).toContain('after');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown keys', () => {
|
||||
it('rejects fields which are not part of the tool', () => {
|
||||
expect(reject('ontime_update_entry', { id: 'entry-1', titel: 'typo' })).toContain('titel');
|
||||
});
|
||||
|
||||
/**
|
||||
* editCurrentProjectData spreads its argument into the stored project, and reacts to a
|
||||
* `logo` key by deleting the current logo file. Only declared fields may reach it.
|
||||
*/
|
||||
it('rejects undeclared writes to project data', () => {
|
||||
expect(reject('ontime_update_project_info', { title: 'Show', logo: 'other.png' })).toContain('logo');
|
||||
expect(parse('ontime_update_project_info', { title: 'Show' })).toEqual({ title: 'Show' });
|
||||
});
|
||||
|
||||
it('rejects entry internals which the services must own', () => {
|
||||
expect(reject('ontime_update_entry', { id: 'entry-1', revision: 99 })).toContain('revision');
|
||||
expect(reject('ontime_update_entry', { id: 'entry-1', parent: 'group-1' })).toContain('parent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('project filenames', () => {
|
||||
/**
|
||||
* Filenames reach the filesystem through join(projectsDir, name), so the invariant that
|
||||
* matters is that nothing which could traverse out of that directory survives parsing.
|
||||
*/
|
||||
const directoryOf = (filename: unknown) => dirname(resolve(PROJECTS_DIR, String(filename)));
|
||||
|
||||
it.each(['../../../etc/passwd', '../sibling.json', '..\\windows\\system32\\show.json', '/etc/shadow', 'show.json'])(
|
||||
'confines %s to the projects directory',
|
||||
(filename) => {
|
||||
expect(directoryOf(parse('ontime_delete_project', { filename }).filename)).toBe(PROJECTS_DIR);
|
||||
expect(directoryOf(parse('ontime_load_project', { filename }).filename)).toBe(PROJECTS_DIR);
|
||||
},
|
||||
);
|
||||
|
||||
it('confines both filenames of a rename', () => {
|
||||
const args = parse('ontime_rename_project', { filename: '../a.json', newFilename: '../../b.json' });
|
||||
expect(directoryOf(args.filename)).toBe(PROJECTS_DIR);
|
||||
expect(directoryOf(args.newFilename)).toBe(PROJECTS_DIR);
|
||||
});
|
||||
|
||||
it('appends the extension and trims the name', () => {
|
||||
expect(parse('ontime_delete_project', { filename: ' show ' })).toEqual({ filename: 'show.json' });
|
||||
expect(parse('ontime_delete_project', { filename: 'show.json' })).toEqual({ filename: 'show.json' });
|
||||
});
|
||||
|
||||
it('rejects a filename which is empty once sanitised', () => {
|
||||
expect(reject('ontime_delete_project', { filename: '..' })).toContain('sanitised');
|
||||
expect(reject('ontime_delete_project', { filename: ' ' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not append an extension when creating a project', () => {
|
||||
// ProjectService appends the extension itself, and de-duplicates the name
|
||||
expect(parse('ontime_create_project', { filename: ' my-show ' })).toEqual({ filename: 'my-show' });
|
||||
expect(directoryOf(parse('ontime_create_project', { filename: '../my-show' }).filename)).toBe(PROJECTS_DIR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch creation', () => {
|
||||
const entry = { type: 'event', title: 'Talk', duration: 600000 };
|
||||
|
||||
it('accepts a group with children', () => {
|
||||
const args = parse('ontime_batch_create_entries', {
|
||||
entries: [{ type: 'group', title: 'Morning', children: [entry] }],
|
||||
});
|
||||
expect(args.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects a group nested inside a group', () => {
|
||||
const issues = reject('ontime_batch_create_entries', {
|
||||
entries: [{ type: 'group', title: 'Morning', children: [{ type: 'group', title: 'Nested' }] }],
|
||||
});
|
||||
expect(issues).toContain('type');
|
||||
});
|
||||
|
||||
it('rejects children on entries which are not groups', () => {
|
||||
expect(
|
||||
reject('ontime_batch_create_entries', { entries: [{ type: 'event', title: 'Talk', children: [entry] }] }),
|
||||
).toContain('Only group entries can have children.');
|
||||
});
|
||||
|
||||
it('rejects unknown keys inside nested children', () => {
|
||||
expect(
|
||||
reject('ontime_batch_create_entries', {
|
||||
entries: [{ type: 'group', title: 'Morning', children: [{ ...entry, titel: 'typo' }] }],
|
||||
}),
|
||||
).toContain('titel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookups which need one of several arguments', () => {
|
||||
it('requires an id or a cue', () => {
|
||||
expect(reject('ontime_get_entry', {})).toContain('Provide id or cue');
|
||||
expect(parse('ontime_get_entry', { cue: '1a' })).toEqual({ cue: '1a' });
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
|
||||
|
||||
/**
|
||||
* Wraps the app authenticate middleware with support for the Authorization header.
|
||||
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
|
||||
* rather than cookies or query params; any other request falls through to the
|
||||
* app middleware, keeping the behaviour of the shared middleware untouched.
|
||||
*/
|
||||
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
|
||||
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
|
||||
if (hasPassword) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
return fallback(req, res, next);
|
||||
};
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* Tool registry for the Ontime MCP server.
|
||||
*
|
||||
* This is the only module aware of the low-level `@modelcontextprotocol/sdk` Server API.
|
||||
* Tools are declared with `defineTool(name, config, handler)` — the same argument shape
|
||||
* `McpServer.registerTool` takes — so migrating to the SDK v2 `registerTool` API means
|
||||
* replacing the two functions below with a registration loop, leaving every tool untouched.
|
||||
*
|
||||
* Responsibilities kept here (and taken over by the SDK on v2):
|
||||
* - deriving the advertised JSON Schema from the tool input schema
|
||||
* - parsing tool arguments before a handler runs
|
||||
*
|
||||
* Argument shape is validated here; business rules (does this ID exist, is this custom
|
||||
* field known) stay in mcp.service.ts.
|
||||
*/
|
||||
|
||||
import {
|
||||
ErrorCode,
|
||||
McpError,
|
||||
type CallToolResult,
|
||||
type ListToolsResult,
|
||||
type ToolAnnotations,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Input schemas must describe an object: MCP tool arguments are always a record */
|
||||
type ToolInput = z.ZodType<Record<string, unknown>, Record<string, unknown>>;
|
||||
|
||||
type ToolConfig<Schema extends ToolInput> = {
|
||||
description: string;
|
||||
annotations: ToolAnnotations;
|
||||
/** Tools which take no arguments declare an empty object, so that they too reject unknown keys */
|
||||
inputSchema: Schema;
|
||||
};
|
||||
|
||||
export type ToolDefinition = {
|
||||
name: string;
|
||||
config: ToolConfig<ToolInput>;
|
||||
handler: (args: never) => Promise<CallToolResult>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Declares a tool, tying the handler argument to the input schema.
|
||||
* The generic is what keeps each entry of a heterogeneous tool array individually typed.
|
||||
*/
|
||||
export function defineTool<const Schema extends ToolInput>(
|
||||
name: string,
|
||||
config: ToolConfig<Schema>,
|
||||
handler: (args: z.output<Schema>) => Promise<CallToolResult>,
|
||||
): ToolDefinition {
|
||||
return { name, config, handler } as ToolDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a tool input schema to the JSON Schema advertised in tools/list.
|
||||
*
|
||||
* `io: 'input'` is required: schemas which sanitise their values with `.transform()`
|
||||
* have an output type the agent must not be shown, and output mode cannot represent them.
|
||||
*/
|
||||
function toInputSchema(schema: ToolInput): ListToolsResult['tools'][number]['inputSchema'] {
|
||||
// `$schema` is metadata about the document, not part of the tool contract
|
||||
const { $schema: _$schema, ...jsonSchema } = z.toJSONSchema(schema, {
|
||||
io: 'input',
|
||||
target: 'draft-7',
|
||||
unrepresentable: 'any',
|
||||
reused: 'inline',
|
||||
});
|
||||
|
||||
return jsonSchema as ListToolsResult['tools'][number]['inputSchema'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the tools/list payload.
|
||||
* Call once at module scope: a new Server is created for every MCP request.
|
||||
*/
|
||||
export function buildToolList(tools: readonly ToolDefinition[]): ListToolsResult['tools'] {
|
||||
return tools.map(({ name, config }) => ({
|
||||
name,
|
||||
description: config.description,
|
||||
annotations: config.annotations,
|
||||
inputSchema: toInputSchema(config.inputSchema),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens validation issues into a single line an agent can act on, eg.
|
||||
* `id: Invalid input: expected string, received undefined; timeStart: ...`
|
||||
*/
|
||||
function formatIssues(error: z.ZodError): string {
|
||||
return error.issues
|
||||
.map((issue) => {
|
||||
const path = issue.path.join('.');
|
||||
return path ? `${path}: ${issue.message}` : issue.message;
|
||||
})
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
export function makeToolRegistry(tools: readonly ToolDefinition[]) {
|
||||
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
|
||||
/**
|
||||
* Validates the arguments of a tool call and runs its handler.
|
||||
*
|
||||
* Malformed calls are protocol errors (as they are in the SDK's own registerTool path),
|
||||
* while failures from the services are returned as tool errors so that the agent can recover.
|
||||
*/
|
||||
return async function dispatchToolCall(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
|
||||
const tool = byName.get(name);
|
||||
if (!tool) {
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
const result = tool.config.inputSchema.safeParse(args);
|
||||
if (!result.success) {
|
||||
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: ${formatIssues(result.error)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await tool.handler(result.data as never);
|
||||
} catch (error) {
|
||||
return err(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Response helpers ----
|
||||
|
||||
const text = (data: unknown): string => JSON.stringify(data);
|
||||
|
||||
export const ok = (data: unknown): CallToolResult => ({ content: [{ type: 'text', text: text(data) }] });
|
||||
|
||||
export const err = (e: unknown): CallToolResult => ({
|
||||
content: [{ type: 'text', text: text({ error: e instanceof Error ? e.message : String(e) }) }],
|
||||
isError: true,
|
||||
});
|
||||
@@ -9,77 +9,70 @@
|
||||
* Keep this file concise and update it when MCP-exposed fields change.
|
||||
*/
|
||||
|
||||
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---- Shared event field schemas ----
|
||||
// Imported by mcp.tools.ts and spread into the tool input schemas.
|
||||
// The JSON Schema advertised to agents is generated from these, so the description
|
||||
// of a field is written once and cannot drift from what is enforced at runtime.
|
||||
// ---- Shared event field JSON schemas ----
|
||||
// Imported by mcp.tools.ts and spread into tool inputSchema.properties.
|
||||
|
||||
export const EVENT_TIMER_FIELDS = {
|
||||
timerType: z
|
||||
.enum(TimerType)
|
||||
.optional()
|
||||
.describe('count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown'),
|
||||
endAction: z
|
||||
.enum(EndAction)
|
||||
.optional()
|
||||
.describe('Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event'),
|
||||
linkStart: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
timerType: {
|
||||
type: 'string',
|
||||
enum: ['count-down', 'count-up', 'clock', 'none'],
|
||||
description: 'count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown',
|
||||
},
|
||||
endAction: {
|
||||
type: 'string',
|
||||
enum: ['none', 'load-next', 'play-next'],
|
||||
description: 'Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event',
|
||||
},
|
||||
linkStart: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
"Link this event's start time to the previous playable event's end time. Linked events allow time changes to propagate through the rundown. Unlinking would prevent propagation and lock this event's start time to the schedule",
|
||||
),
|
||||
countToEnd: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
},
|
||||
countToEnd: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Advanced timing mode: countdown targets the scheduled timeEnd instead of the event duration. This can surprise operators when an event starts late or the schedule shifts; only set true after explaining the behaviour and confirming the user wants it. This can be useful for a deadline, where an event always needs to end at the schedule time, ie: a curfew or a broadcast window.',
|
||||
),
|
||||
timeStrategy: z
|
||||
.enum(TimeStrategy)
|
||||
.optional()
|
||||
.describe(
|
||||
},
|
||||
timeStrategy: {
|
||||
type: 'string',
|
||||
enum: ['lock-duration', 'lock-end'],
|
||||
description:
|
||||
'How linked events adapt to an inherited start: lock-duration recalculates end, lock-end recalculates duration',
|
||||
),
|
||||
timeWarning: z.number().optional().describe('ms before timeEnd to enter warning state (e.g. 300000 = 5 min)'),
|
||||
timeDanger: z.number().optional().describe('ms before timeEnd to enter danger state (e.g. 60000 = 1 min)'),
|
||||
};
|
||||
},
|
||||
timeWarning: { type: 'number', description: 'ms before timeEnd to enter warning state (e.g. 300000 = 5 min)' },
|
||||
timeDanger: { type: 'number', description: 'ms before timeEnd to enter danger state (e.g. 60000 = 1 min)' },
|
||||
} as const;
|
||||
|
||||
export const EVENT_WRITABLE_FIELDS = {
|
||||
cue: z.string().optional().describe('Short free-form cue label — ask the user what naming convention they prefer'),
|
||||
title: z.string().optional().describe('Event title shown in the rundown and views'),
|
||||
note: z.string().optional().describe('Free-text note for production notes or references'),
|
||||
colour: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
cue: { type: 'string', description: 'Short free-form cue label — ask the user what naming convention they prefer' },
|
||||
title: { type: 'string', description: 'Event title shown in the rundown and views' },
|
||||
note: { type: 'string', description: 'Free-text note for production notes or references' },
|
||||
colour: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use, and prefer the default Ontime palette from ontime://style-guide so colours match the editor swatches',
|
||||
),
|
||||
skip: z.boolean().optional().describe('If true, event is skipped during playback'),
|
||||
flag: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Mark the event as a critical operational marker — use sparingly for maximum impact'),
|
||||
custom: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
},
|
||||
skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
|
||||
flag: {
|
||||
type: 'boolean',
|
||||
description: 'Mark the event as a critical operational marker — use sparingly for maximum impact',
|
||||
},
|
||||
custom: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description:
|
||||
'Custom field values keyed by project field key, e.g. { "Camera": "CAM 2" }. Keys are case-sensitive — get them with ontime_get_custom_fields, and create missing fields with ontime_create_custom_field.',
|
||||
),
|
||||
},
|
||||
...EVENT_TIMER_FIELDS,
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const RUNDOWN_TARGET_FIELD = {
|
||||
rundownId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
rundownId: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional target rundown ID. Omit to target the currently loaded live rundown; provide an ID from ontime_list_rundowns to edit a background rundown without loading it.',
|
||||
),
|
||||
};
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ---- Agent-readable schema document ----
|
||||
// Served at ontime://schema. Agents read this once per session to orient themselves
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import { PROMPT_DEFINITIONS, handleGetPrompt } from './mcp.prompts.js';
|
||||
import { RESOURCE_DEFINITIONS, handleReadResource } from './mcp.resources.js';
|
||||
import { TOOL_LIST, handleToolCall } from './mcp.tools.js';
|
||||
import { TOOL_DEFINITIONS, handleToolCall } from './mcp.tools.js';
|
||||
|
||||
export function createMcpServer(): Server {
|
||||
const server = new Server(
|
||||
@@ -20,7 +20,9 @@ export function createMcpServer(): Server {
|
||||
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({ tools: TOOL_LIST }));
|
||||
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({
|
||||
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
||||
const { name, arguments: args = {} } = request.params;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,6 @@ import { socket } from './adapters/WebsocketAdapter.js';
|
||||
// Import Routers
|
||||
import { appRouter } from './api-data/index.js';
|
||||
import { integrationRouter } from './api-integration/integration.router.js';
|
||||
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
|
||||
import { mcpRouter } from './api-mcp/mcp.router.js';
|
||||
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
||||
// Services
|
||||
@@ -102,7 +101,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
|
||||
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
||||
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
|
||||
app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration
|
||||
|
||||
// serve static external files
|
||||
app.use(
|
||||
@@ -207,6 +206,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
|
||||
eventStore.init({
|
||||
clock: state.clock,
|
||||
timer: state.timer,
|
||||
groupTimer: state.groupTimer,
|
||||
message: { ...runtimeStorePlaceholder.message },
|
||||
offset: state.offset,
|
||||
rundown: state.rundown,
|
||||
|
||||
@@ -101,8 +101,16 @@ function getCustomFields(): Readonly<CustomFields> {
|
||||
return db.data.customFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a rundown, replacing any existing entry for the same key.
|
||||
* Takes ownership of `newData` and stores it by reference - the caller must not mutate it
|
||||
* afterward. Every call site either hands over a freshly-built object it never touches again,
|
||||
* or (for the loaded rundown) the cache's own long-lived object, which is already the single
|
||||
* source of truth for that data - aliasing it here costs nothing and avoids a second full
|
||||
* deep copy of the rundown on every commit.
|
||||
*/
|
||||
async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<ProjectRundowns> {
|
||||
db.data.rundowns[rundownKey] = structuredClone(newData);
|
||||
db.data.rundowns[rundownKey] = newData;
|
||||
await persist();
|
||||
return db.data.rundowns;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,17 @@ import { DatabaseModel } from 'ontime-types';
|
||||
* Merges a partial ontime project into a given ontime project
|
||||
*/
|
||||
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
|
||||
const deepExisting = structuredClone(existing);
|
||||
const deepNewData = structuredClone(newData);
|
||||
// rundowns are merged separately below by reference (only the top-level map is copied,
|
||||
// same as the other properties here) - deep-cloning them here would be wasted work,
|
||||
// since a project's rundowns are by far the largest part of this object
|
||||
const { rundowns: existingRundowns, ...existingRest } = existing;
|
||||
const { rundowns: newRundowns = {}, ...newDataRest } = newData;
|
||||
|
||||
const deepExisting = structuredClone(existingRest);
|
||||
const deepNewData = structuredClone(newDataRest);
|
||||
|
||||
// destructure each property to simplify merging not provided ie: ...{} has no effect
|
||||
const {
|
||||
rundowns = {},
|
||||
project = {},
|
||||
settings = {},
|
||||
viewSettings = {},
|
||||
@@ -19,7 +24,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
|
||||
} = deepNewData;
|
||||
|
||||
return {
|
||||
rundowns: { ...existing.rundowns, ...rundowns },
|
||||
rundowns: { ...existingRundowns, ...newRundowns },
|
||||
project: { ...deepExisting.project, ...project },
|
||||
settings: { ...deepExisting.settings, ...settings },
|
||||
viewSettings: { ...deepExisting.viewSettings, ...viewSettings },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user