mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-27 09:59:08 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e29a019376 | |||
| ae16491599 |
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "1.7.0",
|
"@base-ui/react": "1.6.0",
|
||||||
"@codemirror/commands": "^6.0.0",
|
"@codemirror/commands": "^6.0.0",
|
||||||
"@codemirror/lang-css": "^6.0.0",
|
"@codemirror/lang-css": "^6.0.0",
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
@@ -13,12 +13,12 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/open-sans": "^5.2.7",
|
"@fontsource/open-sans": "^5.2.7",
|
||||||
"@mantine/hooks": "^9.5.1",
|
"@mantine/hooks": "^8.3.7",
|
||||||
"@sentry/react": "^10.59.0",
|
"@sentry/react": "^10.59.0",
|
||||||
"@table-nav/react": "^0.0.7",
|
"@table-nav/react": "^0.0.7",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-query-devtools": "^5.101.0",
|
"@tanstack/react-query-devtools": "^5.101.0",
|
||||||
"@tanstack/react-table": "^9.1.2",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@uiw/codemirror-theme-vscode": "^4.25.10",
|
"@uiw/codemirror-theme-vscode": "^4.25.10",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.18.0",
|
"axios": "^1.18.0",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-fast-compare": "^3.2.2",
|
"react-fast-compare": "^3.2.2",
|
||||||
"react-hook-form": "^7.80.0",
|
"react-hook-form": "^7.80.0",
|
||||||
"react-icons": "5.7.0",
|
"react-icons": "5.6.0",
|
||||||
"react-router": "^8.0.1",
|
"react-router": "^8.0.1",
|
||||||
"react-virtuoso": "^4.18.7",
|
"react-virtuoso": "^4.18.7",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
@@ -60,8 +60,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sentry/vite-plugin": "5.4.0",
|
"@sentry/vite-plugin": "5.1.1",
|
||||||
"@types/node": "catalog:",
|
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.12",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
@@ -73,8 +72,8 @@
|
|||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"sass": "^1.57.1",
|
"sass": "^1.57.1",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "8.2.1",
|
"vite": "8.0.1",
|
||||||
"vite-plugin-compression2": "2.5.3",
|
"vite-plugin-compression2": "2.5.1",
|
||||||
"vite-plugin-svgr": "4.5.0",
|
"vite-plugin-svgr": "4.5.0",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
.eyebrow {
|
|
||||||
font-size: $aux-text-size;
|
|
||||||
font-weight: 400;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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,3 +1,7 @@
|
|||||||
|
.separator {
|
||||||
|
border-color: $border-color-ondark;
|
||||||
|
}
|
||||||
|
|
||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -19,15 +23,14 @@
|
|||||||
|
|
||||||
width: 22rem;
|
width: 22rem;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
padding-block: 1rem;
|
||||||
|
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
border-right: 1px solid $gray-1100;
|
border-right: 1px solid $gray-1100;
|
||||||
box-shadow: $box-shadow-right;
|
|
||||||
|
|
||||||
&[data-open] {
|
&[data-open] {
|
||||||
transform: translateX(0%);
|
transform: translateX(0%);
|
||||||
@@ -47,51 +50,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
height: 3.5rem;
|
||||||
min-height: 4rem;
|
padding-inline: 1.5rem;
|
||||||
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-weight: 600;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clientName {
|
|
||||||
font-size: $aux-text-size;
|
|
||||||
color: $gray-600;
|
|
||||||
@include ellipsis-text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-block: 0.5rem 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
.note {
|
||||||
display: flex;
|
margin-left: auto;
|
||||||
flex-direction: column;
|
color: $white-20;
|
||||||
|
|
||||||
&: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 { Dialog } from '@base-ui/react/dialog';
|
||||||
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
|
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
||||||
import { memo, PropsWithChildren } from 'react';
|
import { memo } from 'react';
|
||||||
import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
|
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||||
import { LuCoffee } from 'react-icons/lu';
|
import { LuCoffee } from 'react-icons/lu';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
@@ -14,11 +14,9 @@ import { useClientStore } from '../../stores/clientStore';
|
|||||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||||
import IconButton from '../buttons/IconButton';
|
import IconButton from '../buttons/IconButton';
|
||||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||||
import Eyebrow from '../eyebrow/Eyebrow';
|
|
||||||
import ClientLink from './client-link/ClientLink';
|
import ClientLink from './client-link/ClientLink';
|
||||||
import EditorNavigation from './editor-navigation/EditorNavigation';
|
import EditorNavigation from './editor-navigation/EditorNavigation';
|
||||||
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
|
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
|
||||||
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
|
|
||||||
import OtherAddresses from './other-addresses/OtherAddresses';
|
import OtherAddresses from './other-addresses/OtherAddresses';
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
@@ -35,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
const isSmallScreen = useIsSmallScreen();
|
const isSmallScreen = useIsSmallScreen();
|
||||||
|
|
||||||
const [isRenameOpen, handlers] = useDisclosure(false);
|
const [isRenameOpen, handlers] = useDisclosure(false);
|
||||||
const { fullscreen, toggle } = useFullscreenDocument();
|
const { fullscreen, toggle } = useFullscreen();
|
||||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||||
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
|
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -54,40 +52,34 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
|
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
|
||||||
<Dialog.Popup className={style.drawer}>
|
<Dialog.Popup className={style.drawer}>
|
||||||
<div className={style.header}>
|
<div className={style.header}>
|
||||||
<div className={style.headerText}>
|
<Dialog.Title>Ontime</Dialog.Title>
|
||||||
<Dialog.Title className={style.title}>Ontime</Dialog.Title>
|
<IconButton variant='subtle-white' size='large' onClick={onClose}>
|
||||||
{name && <span className={style.clientName}>{name}</span>}
|
|
||||||
</div>
|
|
||||||
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
|
|
||||||
<IoClose />
|
<IoClose />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.body}>
|
<div className={style.body}>
|
||||||
<MenuGroup label='This screen'>
|
|
||||||
{supportsFullscreen && (
|
{supportsFullscreen && (
|
||||||
<NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} />
|
<NavigationMenuItem active={fullscreen} onClick={toggle}>
|
||||||
)}
|
Toggle Fullscreen
|
||||||
<NavigationMenuToggle
|
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||||
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>
|
||||||
</MenuGroup>
|
)}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<hr className={style.separator} />
|
||||||
|
|
||||||
<MenuGroup label='Ontime app'>
|
|
||||||
<EditorNavigation />
|
<EditorNavigation />
|
||||||
<ClientLink
|
<ClientLink
|
||||||
to='cuesheet'
|
to='cuesheet'
|
||||||
@@ -97,17 +89,13 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Cuesheet
|
Cuesheet
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
<ClientLink
|
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
|
||||||
to='op'
|
|
||||||
current={location.pathname === '/op'}
|
|
||||||
postAction={isSmallScreen ? onClose : undefined}
|
|
||||||
>
|
|
||||||
<IoLockClosedOutline />
|
<IoLockClosedOutline />
|
||||||
Operator
|
Operator
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
</MenuGroup>
|
|
||||||
|
|
||||||
<MenuGroup label='Views'>
|
<hr className={style.separator} />
|
||||||
|
|
||||||
{navigatorConstants.map((route) => (
|
{navigatorConstants.map((route) => (
|
||||||
<ClientLink
|
<ClientLink
|
||||||
key={route.url}
|
key={route.url}
|
||||||
@@ -118,29 +106,21 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
{route.label}
|
{route.label}
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
))}
|
))}
|
||||||
</MenuGroup>
|
|
||||||
|
|
||||||
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
|
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
|
{isLocalhost && (
|
||||||
|
<div>
|
||||||
|
<OtherAddresses currentLocation={location.pathname} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Dialog.Popup>
|
</Dialog.Popup>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog.Root>
|
</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 }) {
|
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { data: urlPresets } = useUrlPresets();
|
const { data: urlPresets } = useUrlPresets();
|
||||||
@@ -149,7 +129,8 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
|
|||||||
if (navPresets.length === 0) return null;
|
if (navPresets.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuGroup label='Presets'>
|
<>
|
||||||
|
<hr className={style.separator} />
|
||||||
{navPresets.map((preset) => (
|
{navPresets.map((preset) => (
|
||||||
<ClientLink
|
<ClientLink
|
||||||
key={preset.alias}
|
key={preset.alias}
|
||||||
@@ -160,6 +141,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
|
|||||||
{preset.alias}
|
{preset.alias}
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
))}
|
))}
|
||||||
</MenuGroup>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useHotkeys } from '@mantine/hooks';
|
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||||
import { memo, useCallback, useState } from 'react';
|
import { memo } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { hasCustomParams } from '../../stores/savedViewParams';
|
import { hasCustomParams } from '../../stores/savedViewParams';
|
||||||
@@ -15,37 +15,21 @@ interface ViewNavigationMenuProps {
|
|||||||
suppressSettings?: boolean;
|
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);
|
export default memo(ViewNavigationMenu);
|
||||||
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
|
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const hasSavedChanges = hasCustomParams(searchParams);
|
const hasSavedChanges = hasCustomParams(searchParams);
|
||||||
|
|
||||||
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
|
|
||||||
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
|
|
||||||
|
|
||||||
useHotkeys([
|
useHotkeys([
|
||||||
[
|
[
|
||||||
'Space',
|
'Space',
|
||||||
(event) => {
|
() => {
|
||||||
if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
|
if (isNavigationLocked) return;
|
||||||
event.preventDefault();
|
menuHandler.toggle();
|
||||||
toggleMenu();
|
|
||||||
},
|
},
|
||||||
{ preventDefault: false },
|
{ preventDefault: true },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'mod + ,',
|
'mod + ,',
|
||||||
@@ -64,11 +48,11 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FloatingNavigation
|
<FloatingNavigation
|
||||||
toggleMenu={isNavigationLocked ? undefined : toggleMenu}
|
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
|
||||||
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
|
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
|
||||||
hasSavedChanges={hasSavedChanges}
|
hasSavedChanges={hasSavedChanges}
|
||||||
/>
|
/>
|
||||||
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />}
|
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-45
@@ -2,68 +2,35 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
padding: 0.625rem 1.25rem;
|
padding: 0.75rem 1.5rem;
|
||||||
gap: 0.625rem;
|
gap: 0.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
border-left: 3px solid transparent;
|
border-left: 4px solid transparent;
|
||||||
color: $gray-200;
|
color: $action-text-color;
|
||||||
font-size: $text-body-size;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
transition-property: color, background-color;
|
transition-property: color;
|
||||||
transition-duration: $transition-time-action;
|
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 {
|
&:hover {
|
||||||
background-color: $white-3;
|
color: $ontime-color;
|
||||||
color: $ui-white;
|
background-color: $gray-1350;
|
||||||
|
|
||||||
> svg {
|
|
||||||
color: $gray-300;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background-color: $white-7;
|
background-color: $border-color-ondark;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus {
|
||||||
outline: 2px solid $blue-500;
|
outline: 2px solid $blue-500;
|
||||||
outline-offset: -2px;
|
background-color: $gray-1350;
|
||||||
background-color: $white-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// toggle rows are labels, the focusable control sits inside them
|
|
||||||
&:focus-within {
|
|
||||||
background-color: $white-3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.current {
|
&.current {
|
||||||
background-color: $bg-active;
|
background-color: $gray-1300;
|
||||||
border-left-color: $blue-400;
|
border-left: 4px solid $action-text-color;
|
||||||
color: $ui-white;
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
> svg {
|
|
||||||
color: $blue-300;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
@include ellipsis-text;
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-5
@@ -1,6 +1,6 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent';
|
import { isKeyEnter } from '../../../utils/keyEvent';
|
||||||
import { cx } from '../../../utils/styleUtils';
|
import { cx } from '../../../utils/styleUtils';
|
||||||
|
|
||||||
import style from './NavigationMenuItem.module.scss';
|
import style from './NavigationMenuItem.module.scss';
|
||||||
@@ -11,7 +11,6 @@ interface NavigationMenuItemProps {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
|
|
||||||
export default function NavigationMenuItem({
|
export default function NavigationMenuItem({
|
||||||
active,
|
active,
|
||||||
className,
|
className,
|
||||||
@@ -25,9 +24,7 @@ export default function NavigationMenuItem({
|
|||||||
role='button'
|
role='button'
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (isKeyEnter(event) || isKeySpace(event)) {
|
if (isKeyEnter(event)) {
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+4
-34
@@ -1,52 +1,22 @@
|
|||||||
.footer {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
border-top: 1px solid $white-10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
padding: 0 1.25rem 0.25rem;
|
font-size: calc(1rem - 2px);
|
||||||
|
margin-left: 1rem;
|
||||||
color: $gray-700;
|
color: $gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.interfaces {
|
.interfaces {
|
||||||
padding: 0.25rem 1.25rem 0;
|
padding: 0.5rem 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.5rem;
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
.interfaceCopy {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.address {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.goIcon {
|
.goIcon {
|
||||||
flex: 0 0 auto;
|
|
||||||
@include rotate-fourty-five;
|
@include rotate-fourty-five;
|
||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
|
|||||||
+12
-15
@@ -3,8 +3,6 @@ import { IoArrowUp } from 'react-icons/io5';
|
|||||||
import useInfo from '../../../hooks-query/useInfo';
|
import useInfo from '../../../hooks-query/useInfo';
|
||||||
import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
|
import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
|
||||||
import CopyTag from '../../copy-tag/CopyTag';
|
import CopyTag from '../../copy-tag/CopyTag';
|
||||||
import Eyebrow from '../../eyebrow/Eyebrow';
|
|
||||||
import { getExternalInterfaces } from './otherAddresses.utils';
|
|
||||||
|
|
||||||
import style from './OtherAddresses.module.scss';
|
import style from './OtherAddresses.module.scss';
|
||||||
|
|
||||||
@@ -14,33 +12,32 @@ interface OtherAddressesProps {
|
|||||||
|
|
||||||
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
|
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
|
||||||
const { data } = useInfo();
|
const { data } = useInfo();
|
||||||
const externalInterfaces = getExternalInterfaces(data.networkInterfaces);
|
|
||||||
|
|
||||||
if (externalInterfaces.length === 0) {
|
// there is no point showing this if we only have one interface
|
||||||
|
if (data.networkInterfaces.length < 2) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.footer}>
|
<>
|
||||||
<div className={style.header}>
|
<div className={style.header}>Accessible on external networks</div>
|
||||||
<Eyebrow>Also available at</Eyebrow>
|
|
||||||
</div>
|
|
||||||
<div className={style.interfaces}>
|
<div className={style.interfaces}>
|
||||||
{externalInterfaces.map((nif) => {
|
{data?.networkInterfaces?.map((nif) => {
|
||||||
|
if (nif.name === 'localhost') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const address = linkToOtherHost(nif.address, currentLocation);
|
const address = linkToOtherHost(nif.address, currentLocation);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={nif.name} className={style.interface}>
|
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
|
||||||
<CopyTag copyValue={address} onClick={() => openLink(address)} size='small'>
|
|
||||||
<span className={style.interfaceCopy}>
|
<span className={style.interfaceCopy}>
|
||||||
<span className={style.address}>{nif.address}</span>
|
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||||
<IoArrowUp className={style.goIcon} />
|
|
||||||
</span>
|
</span>
|
||||||
</CopyTag>
|
</CopyTag>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
-31
@@ -1,31 +0,0 @@
|
|||||||
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
@@ -1,6 +0,0 @@
|
|||||||
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,12 +2,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|
||||||
> span {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
text-align: right;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// attempt to match with ontimeTextInputs
|
// attempt to match with ontimeTextInputs
|
||||||
|
|||||||
@@ -1,77 +1,37 @@
|
|||||||
.multiOptionList {
|
.inline {
|
||||||
overflow-y: auto;
|
display: inline-flex;
|
||||||
max-height: 12rem;
|
align-items: center;
|
||||||
background: $gray-1200;
|
flex-wrap: wrap;
|
||||||
border: 1px solid $gray-1100;
|
gap: 1rem;
|
||||||
border-radius: $component-border-radius-md;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// styles from subtle button
|
||||||
.toggleSelect {
|
.toggleSelect {
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.25rem;
|
||||||
min-height: 2.5rem;
|
padding-inline: 0.5rem;
|
||||||
padding: 0.5rem 0.75rem;
|
height: 2.5rem;
|
||||||
background: transparent;
|
background: $gray-1050;
|
||||||
border: 0;
|
color: $ui-white;
|
||||||
color: $gray-300;
|
|
||||||
line-height: 1em;
|
line-height: 1em;
|
||||||
cursor: pointer;
|
border-radius: $component-border-radius-md;
|
||||||
transition:
|
|
||||||
background-color $transition-time-action,
|
|
||||||
border-color $transition-time-action,
|
|
||||||
color $transition-time-action;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover:not(:disabled):not(:active) {
|
||||||
background: $gray-1000;
|
background: $gray-1000;
|
||||||
color: $ui-white;
|
color: $blue-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active:not(:disabled) {
|
||||||
background: $gray-1100;
|
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 {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
flex: 0 0 auto;
|
margin-left: 0.25rem;
|
||||||
margin-left: auto;
|
width: 0.75em;
|
||||||
width: 0.625rem;
|
height: 0.75em;
|
||||||
height: 0.625rem;
|
|
||||||
background: var(--user-bg, $gray-900);
|
background: var(--user-bg, $gray-900);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { ComponentProps, useEffect, useState } from 'react';
|
|||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { isStringBoolean } from '../../../views/common/viewUtils';
|
import { isStringBoolean } from '../../../views/common/viewUtils';
|
||||||
import { cx } from '../../utils/styleUtils';
|
|
||||||
import Checkbox from '../checkbox/Checkbox';
|
import Checkbox from '../checkbox/Checkbox';
|
||||||
import Input from '../input/input/Input';
|
import Input from '../input/input/Input';
|
||||||
import Select, { SelectOption } from '../select/Select';
|
import Select, { SelectOption } from '../select/Select';
|
||||||
@@ -108,20 +107,18 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input name={id} hidden readOnly value={paramState.join(',')} />
|
<input name={id} hidden readOnly value={paramState.join(',')} />
|
||||||
<div className={style.multiOptionList}>
|
<div className={style.inline}>
|
||||||
{values.map((option) => {
|
{values.map((option) => {
|
||||||
const selected = paramState.includes(option.value);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className={cx([style.toggleSelect, selected && style.selected])}
|
className={style.toggleSelect}
|
||||||
style={{
|
style={{
|
||||||
'--user-bg': option.colour,
|
'--user-bg': option.colour,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected}
|
checked={paramState.includes(option.value)}
|
||||||
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
|
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
|
||||||
/>
|
/>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
.drawerFooter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
gap: $section-spacing;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0 2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -24,11 +34,11 @@
|
|||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
padding-block: 1rem 1.5rem;
|
||||||
|
|
||||||
background-color: $ui-black;
|
background-color: $gray-1250;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
border-left: 1px solid $gray-1100;
|
border-left: 1px solid $gray-1100;
|
||||||
box-shadow: $box-shadow-l1;
|
|
||||||
|
|
||||||
&[data-open] {
|
&[data-open] {
|
||||||
transform: translateX(0%);
|
transform: translateX(0%);
|
||||||
@@ -48,54 +58,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
flex: 0 0 auto;
|
padding-inline: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
height: 3.5rem;
|
||||||
min-height: 4rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerText {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.target {
|
|
||||||
font-size: $aux-text-size;
|
|
||||||
color: $gray-600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1rem;
|
padding-inline: 1rem;
|
||||||
|
padding-bottom: 10vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0.75rem 1rem;
|
padding-inline: 1rem;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
||||||
background-color: $ui-black;
|
|
||||||
border-top: 1px solid $white-10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionList {
|
.sectionList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 2rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { FormEvent, memo } from 'react';
|
|||||||
import { IoClose } from 'react-icons/io5';
|
import { IoClose } from 'react-icons/io5';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { viewLabels } from '../../../viewerConfig';
|
|
||||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||||
import { useSavedViewParams } from '../../stores/savedViewParams';
|
import { useSavedViewParams } from '../../stores/savedViewParams';
|
||||||
@@ -72,17 +71,8 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
|||||||
<Dialog.Backdrop className={style.backdrop} />
|
<Dialog.Backdrop className={style.backdrop} />
|
||||||
<Dialog.Popup className={style.drawer}>
|
<Dialog.Popup className={style.drawer}>
|
||||||
<div className={style.header}>
|
<div className={style.header}>
|
||||||
<div className={style.headerText}>
|
<Dialog.Title>Customise</Dialog.Title>
|
||||||
<Dialog.Title className={style.title}>Customise</Dialog.Title>
|
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
|
||||||
<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 />
|
<IoClose />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
.presetSection {
|
.presetSection {
|
||||||
background-color: $gray-1350;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
color: $gray-300;
|
padding: 1rem 0.5rem;
|
||||||
}
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
.presetList {
|
|
||||||
border-top: 1px solid $white-7;
|
|
||||||
max-height: 10rem;
|
max-height: 10rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
@@ -25,39 +14,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
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 {
|
&.active {
|
||||||
background-color: $bg-active;
|
color: $blue-500;
|
||||||
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 {
|
.presetActions {
|
||||||
margin-left: auto;
|
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,7 +4,6 @@ import { useSearchParams } from 'react-router';
|
|||||||
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
|
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
import Button from '../buttons/Button';
|
import Button from '../buttons/Button';
|
||||||
import Eyebrow from '../eyebrow/Eyebrow';
|
|
||||||
|
|
||||||
import style from './ViewParamsPresets.module.scss';
|
import style from './ViewParamsPresets.module.scss';
|
||||||
|
|
||||||
@@ -27,31 +26,22 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.presetSection}>
|
<div className={style.presetSection}>
|
||||||
<div className={style.header}>
|
|
||||||
<Eyebrow>Saved presets</Eyebrow>
|
|
||||||
</div>
|
|
||||||
<div className={style.presetList}>
|
|
||||||
{viewPresets.map((preset) => {
|
{viewPresets.map((preset) => {
|
||||||
const active = searchParams.get('alias') === preset.alias;
|
const active = searchParams.get('alias') === preset.alias;
|
||||||
return (
|
return (
|
||||||
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
||||||
<span className={style.presetName}>{preset.alias}</span>
|
<div>{preset.alias}</div>
|
||||||
{active ? (
|
|
||||||
<span className={style.current}>Current</span>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant='ghosted'
|
variant={active ? 'ghosted' : 'subtle-white'}
|
||||||
size='small'
|
|
||||||
onClick={() => handleRecall(preset)}
|
onClick={() => handleRecall(preset)}
|
||||||
|
disabled={active}
|
||||||
className={style.presetActions}
|
className={style.presetActions}
|
||||||
>
|
>
|
||||||
Apply
|
{active ? 'Applied' : 'Apply'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-92
@@ -1,118 +1,48 @@
|
|||||||
.section {
|
.section {
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
background-color: $white-3;
|
|
||||||
border: 1px solid $gray-1100;
|
|
||||||
border-radius: $component-border-radius-md;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionHeader {
|
.sectionHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
height: 2rem;
|
||||||
width: 100%;
|
|
||||||
min-height: 2.75rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
font: inherit;
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
color: $gray-300;
|
|
||||||
|
|
||||||
&.collapsible {
|
&.collapsible {
|
||||||
cursor: pointer;
|
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 {
|
.label {
|
||||||
display: grid;
|
margin-top: $section-spacing;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
row-gap: 0.25rem;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
|
color: $label-gray;
|
||||||
&:not(:last-child) {
|
display: flex;
|
||||||
border-bottom: 1px solid $white-7;
|
flex-direction: column;
|
||||||
}
|
gap: 0.25rem;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
.title {
|
||||||
color: $ui-white;
|
font-size: $inner-section-text-size;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
color: $gray-400;
|
font-size: $inner-section-text-size;
|
||||||
font-size: $aux-text-size;
|
display: block;
|
||||||
line-height: 1.4;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useLocalStorage } from '@mantine/hooks';
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
import { IoChevronDown } from 'react-icons/io5';
|
import { IoChevronDown } from 'react-icons/io5';
|
||||||
import { useSearchParams } from 'react-router';
|
|
||||||
|
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
import Eyebrow from '../eyebrow/Eyebrow';
|
|
||||||
import { OptionTitle } from './constants';
|
import { OptionTitle } from './constants';
|
||||||
import ParamInput from './ParamInput';
|
import ParamInput from './ParamInput';
|
||||||
import { type ParamField } from './viewParams.types';
|
import { type ParamField } from './viewParams.types';
|
||||||
@@ -16,74 +14,50 @@ interface ViewParamsSectionProps {
|
|||||||
options: ParamField[];
|
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) {
|
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
||||||
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
// hidden options are inputs without a UI, there is nothing to frame them with
|
const handleCollapse = () => {
|
||||||
if (title === OptionTitle.Hidden) {
|
if (collapsible) {
|
||||||
return <HiddenContents options={options} />;
|
setCollapsed((prev) => !prev);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
const isCollapsed = Boolean(collapsible && collapsed);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={style.section}>
|
<section className={style.section}>
|
||||||
<SectionHeader
|
{title === OptionTitle.Hidden ? (
|
||||||
title={title}
|
<HiddenContents options={options} />
|
||||||
isCustomised={options.some((option) => searchParams.has(option.id))}
|
) : (
|
||||||
collapsed={isCollapsed}
|
<>
|
||||||
onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined}
|
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
|
||||||
/>
|
{title}
|
||||||
{/* collapsed options stay mounted: the form reads its values from the DOM */}
|
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
|
||||||
<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>
|
</div>
|
||||||
|
<SectionContents options={options} collapsed={collapsed} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SectionHeaderProps {
|
interface SectionContentsProps {
|
||||||
title: string;
|
options: ParamField[];
|
||||||
isCustomised: boolean;
|
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
/** a header without a toggle belongs to a section which cannot collapse */
|
|
||||||
onToggle?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeader({ title, isCustomised, collapsed, onToggle }: SectionHeaderProps) {
|
function SectionContents({ options, collapsed }: SectionContentsProps) {
|
||||||
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 (
|
return (
|
||||||
<button
|
<>
|
||||||
type='button'
|
{options.map((option) => {
|
||||||
className={cx([style.sectionHeader, style.collapsible])}
|
return (
|
||||||
aria-expanded={!collapsed}
|
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
|
||||||
onClick={onToggle}
|
<span className={style.title}>{option.title}</span>
|
||||||
>
|
<span className={style.description}>{option.description}</span>
|
||||||
{label}
|
<ParamInput paramField={option} />
|
||||||
<IoChevronDown className={cx([style.chevron, collapsed && style.closed])} />
|
</label>
|
||||||
</button>
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
|
|||||||
return event.key === 'Enter';
|
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 {
|
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
|
||||||
return event.key === 'Escape';
|
return event.key === 'Escape';
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -1,3 +1,5 @@
|
|||||||
|
import { AppMode } from '../ontimeConfig';
|
||||||
|
|
||||||
declare module '*.scss' {
|
declare module '*.scss' {
|
||||||
const content: Record<string, string>;
|
const content: Record<string, string>;
|
||||||
export default content;
|
export default content;
|
||||||
@@ -30,6 +32,39 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare custom data we pass to the table
|
||||||
|
* - `handleUpdate` callback to update the entry when the user edits a cell
|
||||||
|
* - `handleUpdateTimer` callback to update the timer for a specific event
|
||||||
|
* - `options-showDelayedTimes` whether to show or hide delayed times
|
||||||
|
* - `options-hideTableSeconds` whether to hide seconds in the table
|
||||||
|
* - `options-hideIndexColumn` whether to hide the index column
|
||||||
|
* - `options-cuesheetMode` run or edit mode
|
||||||
|
*
|
||||||
|
* And metadata specific for each column
|
||||||
|
* - `canWrite` whether the user can write to this column
|
||||||
|
* - `colour` background colour associated with a custom field
|
||||||
|
*/
|
||||||
|
declare module '@tanstack/react-table' {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
interface TableMeta<TData extends RowData> {
|
||||||
|
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
|
||||||
|
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
|
||||||
|
options: {
|
||||||
|
showDelayedTimes: boolean;
|
||||||
|
hideTableSeconds: boolean;
|
||||||
|
hideIndexColumn: boolean;
|
||||||
|
cuesheetMode: AppMode;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
interface ColumnMeta<TData extends RowData, TValue> {
|
||||||
|
canWrite: boolean;
|
||||||
|
colour?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow passing CSS Properties
|
* Allow passing CSS Properties
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ ul {
|
|||||||
&.active {
|
&.active {
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
border-left-color: $blue-400;
|
border-left-color: $blue-400;
|
||||||
background-color: $bg-active;
|
background-color: rgba($blue-500, 0.16);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ ul {
|
|||||||
&.active {
|
&.active {
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
border-left-color: $blue-400;
|
border-left-color: $blue-400;
|
||||||
background-color: $bg-active;
|
background-color: rgba($blue-500, 0.16);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ $card-padding: 2rem;
|
|||||||
// active rows use a panel-local class today; keep the treatment here so all
|
// 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
|
// settings tables share the same state without each panel owning its colours
|
||||||
tr[class*='current'] {
|
tr[class*='current'] {
|
||||||
background-color: $bg-active;
|
background-color: rgba($blue-500, 0.16);
|
||||||
|
|
||||||
> td:first-child {
|
> td:first-child {
|
||||||
box-shadow: inset 3px 0 $blue-400;
|
box-shadow: inset 3px 0 $blue-400;
|
||||||
|
|||||||
+8
-9
@@ -12,21 +12,20 @@ import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPres
|
|||||||
import { isUrlSafe } from '../../../../../common/utils/regex';
|
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||||
import { enDash } from '../../../../../common/utils/styleUtils';
|
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||||
import { viewLabels } from '../../../../../viewerConfig';
|
|
||||||
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
|
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './URLPresetForm.module.scss';
|
import style from './URLPresetForm.module.scss';
|
||||||
|
|
||||||
const targetOptions: SelectOption<OntimeViewPresettable>[] = [
|
const targetOptions: SelectOption<OntimeViewPresettable>[] = [
|
||||||
{ value: OntimeView.Cuesheet, label: viewLabels[OntimeView.Cuesheet] },
|
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
||||||
{ value: OntimeView.Operator, label: viewLabels[OntimeView.Operator] },
|
{ value: OntimeView.Operator, label: 'Operator' },
|
||||||
{ value: OntimeView.Timer, label: viewLabels[OntimeView.Timer] },
|
{ value: OntimeView.Timer, label: 'Timer' },
|
||||||
{ value: OntimeView.Backstage, label: viewLabels[OntimeView.Backstage] },
|
{ value: OntimeView.Backstage, label: 'Backstage' },
|
||||||
{ value: OntimeView.Timeline, label: viewLabels[OntimeView.Timeline] },
|
{ value: OntimeView.Timeline, label: 'Timeline' },
|
||||||
{ value: OntimeView.StudioClock, label: viewLabels[OntimeView.StudioClock] },
|
{ value: OntimeView.StudioClock, label: 'Studio Clock' },
|
||||||
{ value: OntimeView.Countdown, label: viewLabels[OntimeView.Countdown] },
|
{ value: OntimeView.Countdown, label: 'Countdown' },
|
||||||
{ value: OntimeView.ProjectInfo, label: viewLabels[OntimeView.ProjectInfo] },
|
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const formId = 'url-preset-form';
|
const formId = 'url-preset-form';
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import type { CustomFields } from 'ontime-types';
|
import type { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||||
import { AppMode } from '../../../ontimeConfig';
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
import { makeCuesheetColumns } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
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
|
* Creates column definitions for the rundown table
|
||||||
* Reuses cuesheetColsFactory with preset=undefined for full access
|
* Reuses cuesheetColsFactory with preset=undefined for full access
|
||||||
*/
|
*/
|
||||||
export function makeRundownColumns(customFields: CustomFields): CuesheetColumnDef[] {
|
export function makeRundownColumns(customFields: CustomFields): ColumnDef<ExtendedEntry>[] {
|
||||||
// When preset=undefined, factory defaults to fullRead=true, fullWrite=true
|
// When preset=undefined, factory defaults to fullRead=true, fullWrite=true
|
||||||
// canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run)
|
// canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run)
|
||||||
return makeCuesheetColumns(customFields, AppMode.Edit, undefined);
|
return makeCuesheetColumns(customFields, AppMode.Edit, undefined);
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ $playback-under: $green-500;
|
|||||||
// interface panels
|
// interface panels
|
||||||
$bg-container-l1: $gray-1350;
|
$bg-container-l1: $gray-1350;
|
||||||
$bg-container-l2: $gray-1300;
|
$bg-container-l2: $gray-1300;
|
||||||
$bg-active: rgba($action-blue, 0.16);
|
|
||||||
|
|
||||||
$backdrop-color: rgba(0, 0, 0, 0.7);
|
$backdrop-color: rgba(0, 0, 0, 0.7);
|
||||||
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||||
|
|||||||
@@ -1,30 +1,12 @@
|
|||||||
import { OntimeView } from 'ontime-types';
|
export const navigatorConstants = [
|
||||||
|
{ url: 'timer', label: 'Timer' },
|
||||||
/** User facing name of each view, the single source for view naming in the UI */
|
{ url: 'backstage', label: 'Backstage' },
|
||||||
export const viewLabels: Record<OntimeView, string> = {
|
{ url: 'timeline', label: 'Timeline' },
|
||||||
[OntimeView.Editor]: 'Editor',
|
{ url: 'studio', label: 'Studio Clock' },
|
||||||
[OntimeView.Cuesheet]: 'Cuesheet',
|
{ url: 'countdown', label: 'Countdown' },
|
||||||
[OntimeView.Operator]: 'Operator',
|
{ url: 'info', label: 'Project Info' },
|
||||||
[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
|
// default time format to use for users in 12 hour clocks
|
||||||
export const FORMAT_12 = 'h:mm:ss a';
|
export const FORMAT_12 = 'h:mm:ss a';
|
||||||
// default time format to use for users in 24 hour clocks
|
// default time format to use for users in 24 hour clocks
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import {
|
|||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import type { CuesheetColumnDef } from '../cuesheet-table/cuesheetTable.features';
|
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||||
import { useColumnOrder } from '../cuesheet-table/useColumnManager';
|
import { useColumnOrder } from '../cuesheet-table/useColumnManager';
|
||||||
|
|
||||||
interface CuesheetDndProps {
|
interface CuesheetDndProps {
|
||||||
columns: CuesheetColumnDef[];
|
columns: ColumnDef<ExtendedEntry>[];
|
||||||
tableRoot?: 'editor' | 'cuesheet';
|
tableRoot?: 'editor' | 'cuesheet';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTableNav } from '@table-nav/react';
|
import { useTableNav } from '@table-nav/react';
|
||||||
import { useTable } from '@tanstack/react-table';
|
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||||
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -29,17 +29,12 @@ import GroupRow from './cuesheet-table-elements/GroupRow';
|
|||||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||||
import TableMenu from './cuesheet-table-menu/TableMenu';
|
import TableMenu from './cuesheet-table-menu/TableMenu';
|
||||||
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
|
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
|
||||||
import {
|
|
||||||
CuesheetColumnDef,
|
|
||||||
CuesheetTable as CuesheetTableInstance,
|
|
||||||
cuesheetTableFeatures,
|
|
||||||
} from './cuesheetTable.features';
|
|
||||||
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
||||||
|
|
||||||
import style from './CuesheetTable.module.scss';
|
import style from './CuesheetTable.module.scss';
|
||||||
|
|
||||||
type CuesheetTableBaseProps = {
|
type CuesheetTableBaseProps = {
|
||||||
columns: CuesheetColumnDef[];
|
columns: ColumnDef<ExtendedEntry>[];
|
||||||
cuesheetMode: AppMode;
|
cuesheetMode: AppMode;
|
||||||
source: RundownSource;
|
source: RundownSource;
|
||||||
insertElement?: ReactNode;
|
insertElement?: ReactNode;
|
||||||
@@ -125,8 +120,7 @@ export default function CuesheetTable({
|
|||||||
const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot);
|
const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot);
|
||||||
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
|
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
|
||||||
|
|
||||||
const table = useTable({
|
const table = useReactTable({
|
||||||
features: cuesheetTableFeatures,
|
|
||||||
data: flatRundown,
|
data: flatRundown,
|
||||||
columns,
|
columns,
|
||||||
columnResizeMode: 'onChange',
|
columnResizeMode: 'onChange',
|
||||||
@@ -137,6 +131,7 @@ export default function CuesheetTable({
|
|||||||
},
|
},
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
onColumnSizingChange: setColumnSizing,
|
onColumnSizingChange: setColumnSizing,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
meta,
|
meta,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,7 +195,7 @@ export default function CuesheetTable({
|
|||||||
return colSizes;
|
return colSizes;
|
||||||
// eslint-disable-next-line react-compiler/react-compiler -- unfortunately this is what we need
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
|
||||||
}, [columns, table.state.columnResizing, table.state.columnSizing]);
|
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
|
||||||
|
|
||||||
const allLeafColumns = table.getAllLeafColumns();
|
const allLeafColumns = table.getAllLeafColumns();
|
||||||
const { rows } = table.getRowModel();
|
const { rows } = table.getRowModel();
|
||||||
@@ -219,7 +214,9 @@ export default function CuesheetTable({
|
|||||||
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
|
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
|
||||||
const fixedHeaderContent = useCallback(() => {
|
const fixedHeaderContent = useCallback(() => {
|
||||||
return table.getHeaderGroups().map((headerGroup) => {
|
return table.getHeaderGroups().map((headerGroup) => {
|
||||||
const HeaderComponent = table.state.columnResizing.isResizingColumn ? CuesheetHeader : SortableCuesheetHeader;
|
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
|
||||||
|
? CuesheetHeader
|
||||||
|
: SortableCuesheetHeader;
|
||||||
|
|
||||||
// if the table is being resized, we render non-sortable headers to avoid performance issues
|
// if the table is being resized, we render non-sortable headers to avoid performance issues
|
||||||
return (
|
return (
|
||||||
@@ -282,8 +279,8 @@ interface CuesheetVirtuosoContext {
|
|||||||
columnSizeVars: { [key: string]: number };
|
columnSizeVars: { [key: string]: number };
|
||||||
cursor: string | null;
|
cursor: string | null;
|
||||||
listeners: ReturnType<typeof useTableNav>['listeners'];
|
listeners: ReturnType<typeof useTableNav>['listeners'];
|
||||||
rows: ReturnType<CuesheetTableInstance['getRowModel']>['rows'];
|
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
|
||||||
table: CuesheetTableInstance;
|
table: Table<ExtendedEntry>;
|
||||||
handleAddNew?: (type: SupportedEntry) => void;
|
handleAddNew?: (type: SupportedEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -1,16 +1,16 @@
|
|||||||
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { FlexRender } from '@tanstack/react-table';
|
import { HeaderGroup, flexRender } from '@tanstack/react-table';
|
||||||
import { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import type { CuesheetHeaderGroup } from '../cuesheetTable.features';
|
|
||||||
import { Draggable, SortableCell, TableCell } from './SortableCell';
|
import { Draggable, SortableCell, TableCell } from './SortableCell';
|
||||||
|
|
||||||
import style from '../CuesheetTable.module.scss';
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
interface CuesheetHeaderProps {
|
interface CuesheetHeaderProps {
|
||||||
headerGroup: CuesheetHeaderGroup;
|
headerGroup: HeaderGroup<ExtendedEntry>;
|
||||||
cuesheetMode: AppMode;
|
cuesheetMode: AppMode;
|
||||||
hideIndexColumn: boolean;
|
hideIndexColumn: boolean;
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ export function SortableCuesheetHeader({ headerGroup, cuesheetMode, hideIndexCol
|
|||||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||||
draggable={<Draggable header={header} />}
|
draggable={<Draggable header={header} />}
|
||||||
>
|
>
|
||||||
{header.isPlaceholder ? null : <FlexRender header={header} />}
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</SortableCell>
|
</SortableCell>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -85,7 +85,7 @@ export function CuesheetHeader({ headerGroup, cuesheetMode, hideIndexColumn }: C
|
|||||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||||
draggable={<Draggable header={header} />}
|
draggable={<Draggable header={header} />}
|
||||||
>
|
>
|
||||||
{header.isPlaceholder ? null : <FlexRender header={header} />}
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { FlexRender } from '@tanstack/react-table';
|
import { Table, flexRender } from '@tanstack/react-table';
|
||||||
import { EntryId, RGBColour, SupportedEntry } from 'ontime-types';
|
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
||||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||||
import { CSSProperties, memo, useMemo } from 'react';
|
import { CSSProperties, memo, useMemo } from 'react';
|
||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
|
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
|
||||||
|
|
||||||
import style from './EventRow.module.scss';
|
import style from './EventRow.module.scss';
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ interface EventRowProps {
|
|||||||
skip: boolean;
|
skip: boolean;
|
||||||
parent: EntryId | null;
|
parent: EntryId | null;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
table: CuesheetTable;
|
table: Table<ExtendedEntry<OntimeEntry>>;
|
||||||
injectedStyles?: CSSProperties;
|
injectedStyles?: CSSProperties;
|
||||||
hasCursor?: boolean;
|
hasCursor?: boolean;
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ function EventRow({
|
|||||||
data-testid={`cuesheet-cell-${cell.column.id}`}
|
data-testid={`cuesheet-cell-${cell.column.id}`}
|
||||||
data-column-id={cell.column.id}
|
data-column-id={cell.column.id}
|
||||||
>
|
>
|
||||||
<FlexRender cell={cell} />
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { FlexRender } from '@tanstack/react-table';
|
import { Table, flexRender } from '@tanstack/react-table';
|
||||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||||
import { CSSProperties, memo } from 'react';
|
import { CSSProperties, memo } from 'react';
|
||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
|
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
|
||||||
|
|
||||||
import style from './GroupRow.module.scss';
|
import style from './GroupRow.module.scss';
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ interface GroupRowProps {
|
|||||||
colour: string;
|
colour: string;
|
||||||
rowId: string;
|
rowId: string;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
table: CuesheetTable;
|
table: Table<ExtendedEntry>;
|
||||||
injectedStyles?: CSSProperties;
|
injectedStyles?: CSSProperties;
|
||||||
hasCursor?: boolean;
|
hasCursor?: boolean;
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ function GroupRow({
|
|||||||
}}
|
}}
|
||||||
role='cell'
|
role='cell'
|
||||||
>
|
>
|
||||||
<FlexRender cell={cell} />
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+4
-4
@@ -1,14 +1,14 @@
|
|||||||
import { FlexRender } from '@tanstack/react-table';
|
import { Table, flexRender } from '@tanstack/react-table';
|
||||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||||
import { CSSProperties, memo, useMemo } from 'react';
|
import { CSSProperties, memo, useMemo } from 'react';
|
||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
|
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
|
||||||
|
|
||||||
import style from './MilestoneRow.module.scss';
|
import style from './MilestoneRow.module.scss';
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ interface MilestoneRowProps {
|
|||||||
colour: string;
|
colour: string;
|
||||||
rowId: string;
|
rowId: string;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
table: CuesheetTable;
|
table: Table<ExtendedEntry>;
|
||||||
injectedStyles?: CSSProperties;
|
injectedStyles?: CSSProperties;
|
||||||
hasCursor?: boolean;
|
hasCursor?: boolean;
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ function MilestoneRow({
|
|||||||
}}
|
}}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{canRender && <FlexRender cell={cell} />}
|
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { Header } from '@tanstack/react-table';
|
||||||
import { CSSProperties, ReactNode } from 'react';
|
import { CSSProperties, ReactNode } from 'react';
|
||||||
|
|
||||||
import type { CuesheetHeaderCell } from '../cuesheetTable.features';
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
|
|
||||||
import style from '../CuesheetTable.module.scss';
|
import style from '../CuesheetTable.module.scss';
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ export function TableCell({ colSpan, injectedStyles, children, draggable }: Sort
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DraggableProps {
|
interface DraggableProps {
|
||||||
header: CuesheetHeaderCell;
|
header: Header<ExtendedEntry, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Draggable({ header }: DraggableProps) {
|
export function Draggable({ header }: DraggableProps) {
|
||||||
|
|||||||
+12
-12
@@ -1,3 +1,4 @@
|
|||||||
|
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||||
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
|
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
@@ -7,7 +8,6 @@ import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
|||||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
||||||
import type { CuesheetCellContext, CuesheetColumnDef } from '../cuesheetTable.features';
|
|
||||||
import DurationInput from './DurationInput';
|
import DurationInput from './DurationInput';
|
||||||
import EditableImage from './EditableImage';
|
import EditableImage from './EditableImage';
|
||||||
import FlagCell from './FlagCell';
|
import FlagCell from './FlagCell';
|
||||||
@@ -17,11 +17,11 @@ import MutedText from './MutedText';
|
|||||||
import SingleLineCell from './SingleLineCell';
|
import SingleLineCell from './SingleLineCell';
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
|
|
||||||
function getColumnLabel(column: CuesheetCellContext['column']): string {
|
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
|
||||||
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
|
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
|
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||||
if (!table.options.meta) {
|
if (!table.options.meta) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeEnd({ getValue, row, table, column }: CuesheetCellContext) {
|
function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||||
if (!table.options.meta) {
|
if (!table.options.meta) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ function MakeEnd({ getValue, row, table, column }: CuesheetCellContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
|
function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||||
if (!table.options.meta) {
|
if (!table.options.meta) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeMultiLineField({ row, column, table }: CuesheetCellContext) {
|
function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||||
@@ -160,7 +160,7 @@ function MakeMultiLineField({ row, column, table }: CuesheetCellContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LazyImage({ row, column, table }: CuesheetCellContext) {
|
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||||
@@ -178,7 +178,7 @@ function LazyImage({ row, column, table }: CuesheetCellContext) {
|
|||||||
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
|
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
|
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||||
@@ -207,7 +207,7 @@ function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeFlagField({ row }: CuesheetCellContext) {
|
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
|
||||||
const event = row.original;
|
const event = row.original;
|
||||||
if (!isOntimeEvent(event) || !event.flag) {
|
if (!isOntimeEvent(event) || !event.flag) {
|
||||||
return null;
|
return null;
|
||||||
@@ -215,7 +215,7 @@ function MakeFlagField({ row }: CuesheetCellContext) {
|
|||||||
return <FlagCell />;
|
return <FlagCell />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeCustomField({ row, column, table }: CuesheetCellContext) {
|
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||||
@@ -255,8 +255,8 @@ export function makeCuesheetColumns(
|
|||||||
customFields: CustomFields,
|
customFields: CustomFields,
|
||||||
cuesheetMode: AppMode,
|
cuesheetMode: AppMode,
|
||||||
preset: URLPreset | undefined,
|
preset: URLPreset | undefined,
|
||||||
): CuesheetColumnDef[] {
|
): ColumnDef<ExtendedEntry>[] {
|
||||||
const columnsDef: CuesheetColumnDef[] = [];
|
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
|
||||||
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
|
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
|
||||||
|
|
||||||
if (canRead('flag')) {
|
if (canRead('flag')) {
|
||||||
|
|||||||
+5
-4
@@ -2,6 +2,7 @@ import { Popover } from '@base-ui/react/popover';
|
|||||||
import { Toggle } from '@base-ui/react/toggle';
|
import { Toggle } from '@base-ui/react/toggle';
|
||||||
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
||||||
import { Toolbar } from '@base-ui/react/toolbar';
|
import { Toolbar } from '@base-ui/react/toolbar';
|
||||||
|
import type { Column } from '@tanstack/react-table';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
|
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
|
||||||
|
|
||||||
@@ -9,9 +10,9 @@ import Button from '../../../../common/components/buttons/Button';
|
|||||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||||
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||||
import type { CuesheetColumn } from '../cuesheetTable.features';
|
|
||||||
import CuesheetShareModal from './CuesheetShareModal';
|
import CuesheetShareModal from './CuesheetShareModal';
|
||||||
|
|
||||||
import style from './CuesheetTableSettings.module.scss';
|
import style from './CuesheetTableSettings.module.scss';
|
||||||
@@ -36,7 +37,7 @@ type TableModeControls = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface CuesheetTableHeaderToolbarProps {
|
interface CuesheetTableHeaderToolbarProps {
|
||||||
columns: CuesheetColumn[];
|
columns: Column<ExtendedEntry, unknown>[];
|
||||||
optionsStore: TableHeaderOptionsStore;
|
optionsStore: TableHeaderOptionsStore;
|
||||||
handleResetResizing: () => void;
|
handleResetResizing: () => void;
|
||||||
handleResetReordering: () => void;
|
handleResetReordering: () => void;
|
||||||
@@ -110,7 +111,7 @@ interface ViewSettingsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ColumnSettingsProps {
|
interface ColumnSettingsProps {
|
||||||
columns: CuesheetColumn[];
|
columns: Column<ExtendedEntry, unknown>[];
|
||||||
handleResetResizing: () => void;
|
handleResetResizing: () => void;
|
||||||
handleResetReordering: () => void;
|
handleResetReordering: () => void;
|
||||||
handleClearToggles: () => void;
|
handleClearToggles: () => void;
|
||||||
@@ -202,7 +203,7 @@ function ColumnSettings({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Editor.Label key={`${column.id}-${visible}`} className={style.option}>
|
<Editor.Label key={`${column.id}-${visible}`} className={style.option}>
|
||||||
<Checkbox defaultChecked={visible} onCheckedChange={(checked) => column.toggleVisibility(checked)} />
|
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} />
|
||||||
{columnHeader as ReactNode}
|
{columnHeader as ReactNode}
|
||||||
</Editor.Label>
|
</Editor.Label>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
transition: background-color 0.2s ease-in-out;
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
|
||||||
&[data-background-rundown='true'] {
|
&[data-background-rundown='true'] {
|
||||||
background-color: $bg-active;
|
background-color: rgba($ontime-color, 0.5);
|
||||||
|
|
||||||
.apart::before {
|
.apart::before {
|
||||||
content: 'BACKGROUND EDIT';
|
content: 'BACKGROUND EDIT';
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import {
|
|
||||||
columnOrderingFeature,
|
|
||||||
columnResizingFeature,
|
|
||||||
columnSizingFeature,
|
|
||||||
columnVisibilityFeature,
|
|
||||||
metaHelper,
|
|
||||||
tableFeatures,
|
|
||||||
} from '@tanstack/react-table';
|
|
||||||
import type { CellContext, Column, ColumnDef, Header, HeaderGroup, Table } from '@tanstack/react-table';
|
|
||||||
import type { TimeField } from 'ontime-types';
|
|
||||||
|
|
||||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
|
||||||
import type { AppMode } from '../../../ontimeConfig';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom data we pass to the table
|
|
||||||
* - `handleUpdate` callback to update the entry when the user edits a cell
|
|
||||||
* - `handleUpdateTimer` callback to update the timer for a specific event
|
|
||||||
* - `options-showDelayedTimes` whether to show or hide delayed times
|
|
||||||
* - `options-hideTableSeconds` whether to hide seconds in the table
|
|
||||||
* - `options-hideIndexColumn` whether to hide the index column
|
|
||||||
* - `options-cuesheetMode` run or edit mode
|
|
||||||
*/
|
|
||||||
export interface CuesheetTableMeta {
|
|
||||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
|
|
||||||
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
|
|
||||||
options: {
|
|
||||||
showDelayedTimes: boolean;
|
|
||||||
hideTableSeconds: boolean;
|
|
||||||
hideIndexColumn: boolean;
|
|
||||||
cuesheetMode: AppMode;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Metadata specific for each column
|
|
||||||
* - `canWrite` whether the user can write to this column
|
|
||||||
* - `colour` background colour associated with a custom field
|
|
||||||
*/
|
|
||||||
export interface CuesheetColumnMeta {
|
|
||||||
canWrite: boolean;
|
|
||||||
colour?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Features registered in the cuesheet and rundown tables.
|
|
||||||
* In v9 an API only exists once its feature is registered, so this list is the
|
|
||||||
* source of truth for what the table can do:
|
|
||||||
* - `columnOrderingFeature`: user reorders columns by dragging the headers
|
|
||||||
* - `columnVisibilityFeature`: user toggles columns in the table settings
|
|
||||||
* - `columnSizingFeature`: column widths, exposed to CSS as custom properties
|
|
||||||
* - `columnResizingFeature`: the drag handle in the header (requires sizing)
|
|
||||||
*
|
|
||||||
* The `tableMeta` / `columnMeta` slots replace the v8 global module augmentation:
|
|
||||||
* they scope our meta types to this table instead of every table in the app.
|
|
||||||
*/
|
|
||||||
export const cuesheetTableFeatures = tableFeatures({
|
|
||||||
columnOrderingFeature,
|
|
||||||
columnVisibilityFeature,
|
|
||||||
columnSizingFeature,
|
|
||||||
columnResizingFeature,
|
|
||||||
tableMeta: metaHelper<CuesheetTableMeta>(),
|
|
||||||
columnMeta: metaHelper<CuesheetColumnMeta>(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CuesheetFeatures = typeof cuesheetTableFeatures;
|
|
||||||
|
|
||||||
/** Convenience aliases so consumers do not need to repeat the feature generic */
|
|
||||||
export type CuesheetColumnDef = ColumnDef<CuesheetFeatures, ExtendedEntry>;
|
|
||||||
export type CuesheetTable = Table<CuesheetFeatures, ExtendedEntry>;
|
|
||||||
export type CuesheetCellContext = CellContext<CuesheetFeatures, ExtendedEntry>;
|
|
||||||
export type CuesheetHeaderGroup = HeaderGroup<CuesheetFeatures, ExtendedEntry>;
|
|
||||||
export type CuesheetHeaderCell = Header<CuesheetFeatures, ExtendedEntry, unknown>;
|
|
||||||
export type CuesheetColumn = Column<CuesheetFeatures, ExtendedEntry, unknown>;
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useLocalStorage } from '@mantine/hooks';
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
import { ColumnSizingState, Updater } from '@tanstack/react-table';
|
import { ColumnDef, ColumnSizingState, Updater } from '@tanstack/react-table';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { debounce } from '../../../common/utils/debounce';
|
import { debounce } from '../../../common/utils/debounce';
|
||||||
import { makeStageKey } from '../../../common/utils/localStorage';
|
import { makeStageKey } from '../../../common/utils/localStorage';
|
||||||
import type { CuesheetColumnDef } from './cuesheetTable.features';
|
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||||
|
|
||||||
type TableRoot = 'editor' | 'cuesheet';
|
type TableRoot = 'editor' | 'cuesheet';
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export function useColumnSizes(tableRoot: TableRoot = 'cuesheet') {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useColumnOrder(columns: CuesheetColumnDef[], tableRoot: TableRoot = 'cuesheet') {
|
export function useColumnOrder(columns: ColumnDef<ExtendedEntry>[], tableRoot: TableRoot = 'cuesheet') {
|
||||||
const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]);
|
const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]);
|
||||||
|
|
||||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
|||||||
{
|
{
|
||||||
id: 'timerColour',
|
id: 'timerColour',
|
||||||
title: 'Timer Colour',
|
title: 'Timer Colour',
|
||||||
description: 'Timer colour. Default: #F6F6F6',
|
description: 'Timer colour. Default: #f6f6f6',
|
||||||
type: 'colour',
|
type: 'colour',
|
||||||
defaultValue: 'f6f6f6',
|
defaultValue: 'f6f6f6',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "38.2.1",
|
"electron": "38.2.1",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.9.1",
|
||||||
"wait-on": "^9.0.0"
|
"wait-on": "^7.2.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
|
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"cookie": "1.0.2",
|
"cookie": "1.0.2",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.6",
|
"cors": "2.8.6",
|
||||||
"dotenv": "^17.0.0",
|
"dotenv": "^16.0.1",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-static-gzip": "3.0.1",
|
"express-static-gzip": "3.0.1",
|
||||||
"express-validator": "7.3.2",
|
"express-validator": "7.3.2",
|
||||||
@@ -22,7 +22,8 @@
|
|||||||
"osc-min": "2.1.2",
|
"osc-min": "2.1.2",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5",
|
||||||
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
@@ -31,11 +32,11 @@
|
|||||||
"@types/multer": "2.1.0",
|
"@types/multer": "2.1.0",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.10",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.24.0",
|
||||||
"ontime-types": "workspace:*",
|
"ontime-types": "workspace:*",
|
||||||
"server-timing": "^3.3.3",
|
"server-timing": "^3.3.3",
|
||||||
"ts-essentials": "catalog:",
|
"ts-essentials": "catalog:",
|
||||||
"tsx": "^4.23.12",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { deepEqual } from 'fast-equals';
|
|
||||||
import {
|
import {
|
||||||
EndAction,
|
EndAction,
|
||||||
OntimeEntry,
|
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
OntimeGroup,
|
OntimeGroup,
|
||||||
OntimeMilestone,
|
OntimeMilestone,
|
||||||
@@ -10,21 +8,13 @@ import {
|
|||||||
TimerType,
|
TimerType,
|
||||||
Trigger,
|
Trigger,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils';
|
||||||
MILLIS_PER_HOUR,
|
|
||||||
MILLIS_PER_MINUTE,
|
|
||||||
createDelay,
|
|
||||||
createEvent,
|
|
||||||
createGroup,
|
|
||||||
createMilestone,
|
|
||||||
} from 'ontime-utils';
|
|
||||||
import { assertType } from 'vitest';
|
import { assertType } from 'vitest';
|
||||||
|
|
||||||
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
|
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||||
import { parseRundown } from '../rundown.parser.js';
|
import { parseRundown } from '../rundown.parser.js';
|
||||||
import {
|
import {
|
||||||
calculateDayOffset,
|
calculateDayOffset,
|
||||||
cloneEntryData,
|
|
||||||
deleteById,
|
deleteById,
|
||||||
doesInvalidateMetadata,
|
doesInvalidateMetadata,
|
||||||
getIntegerAndFraction,
|
getIntegerAndFraction,
|
||||||
@@ -725,68 +715,3 @@ describe('eventDurationMatchGroupTarget()', () => {
|
|||||||
expect(result).toStrictEqual(null);
|
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,7 +27,6 @@ import {
|
|||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { addToRundown, createGroup, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils';
|
import { addToRundown, createGroup, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils';
|
||||||
import type { DeepReadonly } from 'ts-essentials';
|
|
||||||
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
import { consoleError } from '../../utils/console.js';
|
import { consoleError } from '../../utils/console.js';
|
||||||
@@ -35,7 +34,6 @@ import { ProcessedRundownMetadata, makeRundownMetadata } from './rundown.parser.
|
|||||||
import type { RundownMetadata } from './rundown.types.js';
|
import type { RundownMetadata } from './rundown.types.js';
|
||||||
import {
|
import {
|
||||||
applyPatchToEntry,
|
applyPatchToEntry,
|
||||||
cloneRundown,
|
|
||||||
cloneSimpleRundownEntry,
|
cloneSimpleRundownEntry,
|
||||||
deleteById,
|
deleteById,
|
||||||
doesInvalidateMetadata,
|
doesInvalidateMetadata,
|
||||||
@@ -45,14 +43,9 @@ import {
|
|||||||
} from './rundown.utils.js';
|
} 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.
|
|
||||||
*/
|
*/
|
||||||
let cachedRundown: Rundown = {
|
const cachedRundown: Rundown = {
|
||||||
id: '',
|
id: '',
|
||||||
title: '',
|
title: '',
|
||||||
order: [],
|
order: [],
|
||||||
@@ -86,16 +79,9 @@ export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetada
|
|||||||
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
|
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
|
||||||
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
|
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;
|
customFields: CustomFields;
|
||||||
rundown: R;
|
rundown: Rundown;
|
||||||
|
|
||||||
commit: (shouldProcess?: boolean) => Promise<{
|
commit: (shouldProcess?: boolean) => Promise<{
|
||||||
rundown: Readonly<Rundown>;
|
rundown: Readonly<Rundown>;
|
||||||
@@ -116,17 +102,11 @@ type TransactionOptions = {
|
|||||||
rundownId?: string;
|
rundownId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createTransaction(options: TransactionOptions & { mutableRundown: true }): Transaction<Rundown>;
|
export function createTransaction(options: TransactionOptions): Transaction {
|
||||||
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 targetId = options.rundownId ?? cachedRundown.id;
|
||||||
const isLoaded = targetId === cachedRundown.id;
|
const isLoaded = targetId === cachedRundown.id;
|
||||||
const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown);
|
const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown);
|
||||||
const rundown = options.mutableRundown ? cloneRundown(sourceRundown) : sourceRundown;
|
const rundown = options.mutableRundown ? structuredClone(sourceRundown) : sourceRundown;
|
||||||
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
|
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -727,25 +707,21 @@ export const customFieldMutation = {
|
|||||||
* Expose function to add an initial rundown to the system
|
* Expose function to add an initial rundown to the system
|
||||||
*/
|
*/
|
||||||
export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) {
|
export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) {
|
||||||
const rundown = cloneRundown(initialRundown);
|
const rundown = structuredClone(initialRundown);
|
||||||
const customFields = structuredClone(initialCustomFields);
|
const customFields = structuredClone(initialCustomFields);
|
||||||
const processedData = processRundown(rundown, customFields, { mutate: true });
|
const processedData = processRundown(rundown, customFields, { mutate: true });
|
||||||
|
|
||||||
|
// update the cache values
|
||||||
|
cachedRundown.id = rundown.id;
|
||||||
|
cachedRundown.title = rundown.title;
|
||||||
projectCustomFields = customFields;
|
projectCustomFields = customFields;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
// 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;
|
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
||||||
|
cachedRundown.entries = entries;
|
||||||
// a fresh object, so that the record already stored for a previously loaded rundown keeps
|
cachedRundown.order = order;
|
||||||
// pointing at that rundown's data - see the note on cachedRundown
|
cachedRundown.flatOrder = metadata.flatEntryOrder;
|
||||||
cachedRundown = {
|
cachedRundown.revision = rundown.revision;
|
||||||
id: rundown.id,
|
|
||||||
title: rundown.title,
|
|
||||||
entries,
|
|
||||||
order,
|
|
||||||
flatOrder: metadata.flatEntryOrder,
|
|
||||||
revision: rundown.revision,
|
|
||||||
};
|
|
||||||
rundownMetadata = metadata;
|
rundownMetadata = metadata;
|
||||||
|
|
||||||
// defer writing to the database
|
// defer writing to the database
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
import { makeNewRundown } from '../../models/dataModel.js';
|
import { makeNewRundown } from '../../models/dataModel.js';
|
||||||
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||||
import { RundownMetadata } from './rundown.types.js';
|
import { RundownMetadata } from './rundown.types.js';
|
||||||
import { calculateDayOffset, cleanupCustomFields, cloneEntryData } from './rundown.utils.js';
|
import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a rundowns object along with the project custom fields
|
* 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 {
|
function process<T extends OntimeEntry>(entry: T, childOfGroup: EntryId | null): T {
|
||||||
return processEntry(rundownMeta, customFields, mutate ? entry : cloneEntryData(entry), childOfGroup);
|
return processEntry(rundownMeta, customFields, mutate ? entry : structuredClone(entry), childOfGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMetadata(): ProcessedRundownMetadata {
|
function getMetadata(): ProcessedRundownMetadata {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import {
|
|||||||
import { parseRundown } from './rundown.parser.js';
|
import { parseRundown } from './rundown.parser.js';
|
||||||
import type { RundownMetadata } from './rundown.types.js';
|
import type { RundownMetadata } from './rundown.types.js';
|
||||||
import {
|
import {
|
||||||
cloneRundown,
|
|
||||||
generateEvent,
|
generateEvent,
|
||||||
getFirstInsertId,
|
getFirstInsertId,
|
||||||
getIntegerAndFraction,
|
getIntegerAndFraction,
|
||||||
@@ -627,7 +626,7 @@ export async function editCustomField(
|
|||||||
// ... reassign references in the background rundowns
|
// ... reassign references in the background rundowns
|
||||||
for (const rundownId of Object.keys(projectRundowns)) {
|
for (const rundownId of Object.keys(projectRundowns)) {
|
||||||
if (rundownId !== rundown.id) {
|
if (rundownId !== rundown.id) {
|
||||||
const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
|
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||||
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
|
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
|
||||||
await updateBackgroundRundown(rundownId, backgroundRundown);
|
await updateBackgroundRundown(rundownId, backgroundRundown);
|
||||||
}
|
}
|
||||||
@@ -667,7 +666,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
|
|||||||
// remove references in the background rundowns
|
// remove references in the background rundowns
|
||||||
for (const rundownId of Object.keys(projectRundowns)) {
|
for (const rundownId of Object.keys(projectRundowns)) {
|
||||||
if (rundownId !== rundown.id) {
|
if (rundownId !== rundown.id) {
|
||||||
const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
|
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||||
customFieldMutation.removeUsages(backgroundRundown, key);
|
customFieldMutation.removeUsages(backgroundRundown, key);
|
||||||
await updateBackgroundRundown(rundownId, backgroundRundown);
|
await updateBackgroundRundown(rundownId, backgroundRundown);
|
||||||
}
|
}
|
||||||
@@ -819,7 +818,7 @@ export async function renameRundown(id: string, title: string) {
|
|||||||
const dataProvider = getDataProvider();
|
const dataProvider = getDataProvider();
|
||||||
const rundown = dataProvider.getRundown(id);
|
const rundown = dataProvider.getRundown(id);
|
||||||
|
|
||||||
await dataProvider.setRundown(id, { ...rundown, title, revision: rundown.revision + 1 });
|
await dataProvider.setRundown(id, { ...rundown, title });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If we are modifying the loaded rundown we re-init it
|
* If we are modifying the loaded rundown we re-init it
|
||||||
@@ -847,7 +846,7 @@ export async function duplicateExistingRundown(id: string) {
|
|||||||
const dataProvider = getDataProvider();
|
const dataProvider = getDataProvider();
|
||||||
const rundown = dataProvider.getRundown(id);
|
const rundown = dataProvider.getRundown(id);
|
||||||
|
|
||||||
const duplicatedRundown: Rundown = cloneRundown(rundown);
|
const duplicatedRundown: Rundown = structuredClone(rundown);
|
||||||
duplicatedRundown.id = generateId();
|
duplicatedRundown.id = generateId();
|
||||||
duplicatedRundown.title = `Copy of ${rundown.title}`;
|
duplicatedRundown.title = `Copy of ${rundown.title}`;
|
||||||
duplicatedRundown.revision = 0;
|
duplicatedRundown.revision = 0;
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ export function mergeRundownPreservingFields(
|
|||||||
const structure = isOntimeGroup(incomingEntry)
|
const structure = isOntimeGroup(incomingEntry)
|
||||||
? { entries: incomingEntry.entries }
|
? { entries: incomingEntry.entries }
|
||||||
: { parent: incomingEntry.parent };
|
: { parent: incomingEntry.parent };
|
||||||
entries[id] = cloneEntryData({ ...merged, ...structure });
|
entries[id] = structuredClone({ ...merged, ...structure });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -353,19 +353,19 @@ export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundo
|
|||||||
|
|
||||||
/** List of event properties which do not need the rundown to be regenerated */
|
/** List of event properties which do not need the rundown to be regenerated */
|
||||||
enum RegenerateWhitelist {
|
enum RegenerateWhitelist {
|
||||||
id, // adding it for completeness, users cannot change ID
|
'id', // adding it for completeness, users cannot change ID
|
||||||
type, // adding it for completeness, users cannot change ID
|
'type', // adding it for completeness, users cannot change ID
|
||||||
cue,
|
'cue',
|
||||||
title,
|
'title',
|
||||||
note,
|
'note',
|
||||||
endAction,
|
'endAction',
|
||||||
timerType,
|
'timerType',
|
||||||
countToEnd,
|
'countToEnd',
|
||||||
colour,
|
'colour',
|
||||||
timeWarning,
|
'timeWarning',
|
||||||
timeDanger,
|
'timeDanger',
|
||||||
custom,
|
'custom',
|
||||||
triggers,
|
'triggers',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -499,61 +499,6 @@ export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): Ont
|
|||||||
throw new Error(`Unsupported entry type for cloning: ${entry}`);
|
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
|
* Utility for calculating if the current events should have a day offset
|
||||||
* @param current the current event under test
|
* @param current the current event under test
|
||||||
|
|||||||
@@ -464,8 +464,9 @@ export async function upload(sheetId: string, options: ImportMap) {
|
|||||||
sheetOrder.forEach((entryId, index) => {
|
sheetOrder.forEach((entryId, index) => {
|
||||||
const isGroupEnd = entryId.startsWith('group-end-');
|
const isGroupEnd = entryId.startsWith('group-end-');
|
||||||
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
|
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
|
||||||
// cellRequestFromEvent only reads the entry to build a cell request, no clone is needed
|
const entry = isGroupEnd
|
||||||
const entry = isGroupEnd ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) : rundown.entries[id];
|
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
|
||||||
|
: structuredClone(rundown.entries[id]);
|
||||||
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
|
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* 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,70 +9,77 @@
|
|||||||
* Keep this file concise and update it when MCP-exposed fields change.
|
* Keep this file concise and update it when MCP-exposed fields change.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ---- Shared event field JSON schemas ----
|
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
// Imported by mcp.tools.ts and spread into tool inputSchema.properties.
|
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.
|
||||||
|
|
||||||
export const EVENT_TIMER_FIELDS = {
|
export const EVENT_TIMER_FIELDS = {
|
||||||
timerType: {
|
timerType: z
|
||||||
type: 'string',
|
.enum(TimerType)
|
||||||
enum: ['count-down', 'count-up', 'clock', 'none'],
|
.optional()
|
||||||
description: 'count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown',
|
.describe('count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown'),
|
||||||
},
|
endAction: z
|
||||||
endAction: {
|
.enum(EndAction)
|
||||||
type: 'string',
|
.optional()
|
||||||
enum: ['none', 'load-next', 'play-next'],
|
.describe('Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event'),
|
||||||
description: 'Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event',
|
linkStart: z
|
||||||
},
|
.boolean()
|
||||||
linkStart: {
|
.optional()
|
||||||
type: 'boolean',
|
.describe(
|
||||||
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",
|
"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: {
|
countToEnd: z
|
||||||
type: 'boolean',
|
.boolean()
|
||||||
description:
|
.optional()
|
||||||
|
.describe(
|
||||||
'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.',
|
'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: {
|
timeStrategy: z
|
||||||
type: 'string',
|
.enum(TimeStrategy)
|
||||||
enum: ['lock-duration', 'lock-end'],
|
.optional()
|
||||||
description:
|
.describe(
|
||||||
'How linked events adapt to an inherited start: lock-duration recalculates end, lock-end recalculates duration',
|
'How linked events adapt to an inherited start: lock-duration recalculates end, lock-end recalculates duration',
|
||||||
},
|
),
|
||||||
timeWarning: { type: 'number', description: 'ms before timeEnd to enter warning state (e.g. 300000 = 5 min)' },
|
timeWarning: z.number().optional().describe('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)' },
|
timeDanger: z.number().optional().describe('ms before timeEnd to enter danger state (e.g. 60000 = 1 min)'),
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export const EVENT_WRITABLE_FIELDS = {
|
export const EVENT_WRITABLE_FIELDS = {
|
||||||
cue: { type: 'string', description: 'Short free-form cue label — ask the user what naming convention they prefer' },
|
cue: z.string().optional().describe('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' },
|
title: z.string().optional().describe('Event title shown in the rundown and views'),
|
||||||
note: { type: 'string', description: 'Free-text note for production notes or references' },
|
note: z.string().optional().describe('Free-text note for production notes or references'),
|
||||||
colour: {
|
colour: z
|
||||||
type: 'string',
|
.string()
|
||||||
description:
|
.optional()
|
||||||
|
.describe(
|
||||||
'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',
|
'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: { type: 'boolean', description: 'If true, event is skipped during playback' },
|
skip: z.boolean().optional().describe('If true, event is skipped during playback'),
|
||||||
flag: {
|
flag: z
|
||||||
type: 'boolean',
|
.boolean()
|
||||||
description: 'Mark the event as a critical operational marker — use sparingly for maximum impact',
|
.optional()
|
||||||
},
|
.describe('Mark the event as a critical operational marker — use sparingly for maximum impact'),
|
||||||
custom: {
|
custom: z
|
||||||
type: 'object',
|
.record(z.string(), z.string())
|
||||||
additionalProperties: { type: 'string' },
|
.optional()
|
||||||
description:
|
.describe(
|
||||||
'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.',
|
'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,
|
...EVENT_TIMER_FIELDS,
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export const RUNDOWN_TARGET_FIELD = {
|
export const RUNDOWN_TARGET_FIELD = {
|
||||||
rundownId: {
|
rundownId: z
|
||||||
type: 'string',
|
.string()
|
||||||
description:
|
.optional()
|
||||||
|
.describe(
|
||||||
'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.',
|
'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 ----
|
// ---- Agent-readable schema document ----
|
||||||
// Served at ontime://schema. Agents read this once per session to orient themselves
|
// 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 { PROMPT_DEFINITIONS, handleGetPrompt } from './mcp.prompts.js';
|
||||||
import { RESOURCE_DEFINITIONS, handleReadResource } from './mcp.resources.js';
|
import { RESOURCE_DEFINITIONS, handleReadResource } from './mcp.resources.js';
|
||||||
import { TOOL_DEFINITIONS, handleToolCall } from './mcp.tools.js';
|
import { TOOL_LIST, handleToolCall } from './mcp.tools.js';
|
||||||
|
|
||||||
export function createMcpServer(): Server {
|
export function createMcpServer(): Server {
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
@@ -20,9 +20,7 @@ export function createMcpServer(): Server {
|
|||||||
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
||||||
);
|
);
|
||||||
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({
|
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({ tools: TOOL_LIST }));
|
||||||
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
|
|
||||||
}));
|
|
||||||
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
||||||
const { name, arguments: args = {} } = request.params;
|
const { name, arguments: args = {} } = request.params;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import { socket } from './adapters/WebsocketAdapter.js';
|
|||||||
// Import Routers
|
// Import Routers
|
||||||
import { appRouter } from './api-data/index.js';
|
import { appRouter } from './api-data/index.js';
|
||||||
import { integrationRouter } from './api-integration/integration.router.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 { mcpRouter } from './api-mcp/mcp.router.js';
|
||||||
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
||||||
// Services
|
// Services
|
||||||
@@ -101,7 +102,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
|
|||||||
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||||
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||||
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
||||||
app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration
|
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
|
||||||
|
|
||||||
// serve static external files
|
// serve static external files
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -101,16 +101,8 @@ function getCustomFields(): Readonly<CustomFields> {
|
|||||||
return db.data.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> {
|
async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<ProjectRundowns> {
|
||||||
db.data.rundowns[rundownKey] = newData;
|
db.data.rundowns[rundownKey] = structuredClone(newData);
|
||||||
await persist();
|
await persist();
|
||||||
return db.data.rundowns;
|
return db.data.rundowns;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,12 @@ import { DatabaseModel } from 'ontime-types';
|
|||||||
* Merges a partial ontime project into a given ontime project
|
* Merges a partial ontime project into a given ontime project
|
||||||
*/
|
*/
|
||||||
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
|
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
|
||||||
// rundowns are merged separately below by reference (only the top-level map is copied,
|
const deepExisting = structuredClone(existing);
|
||||||
// same as the other properties here) - deep-cloning them here would be wasted work,
|
const deepNewData = structuredClone(newData);
|
||||||
// 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
|
// destructure each property to simplify merging not provided ie: ...{} has no effect
|
||||||
const {
|
const {
|
||||||
|
rundowns = {},
|
||||||
project = {},
|
project = {},
|
||||||
settings = {},
|
settings = {},
|
||||||
viewSettings = {},
|
viewSettings = {},
|
||||||
@@ -24,7 +19,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
|
|||||||
} = deepNewData;
|
} = deepNewData;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rundowns: { ...existingRundowns, ...newRundowns },
|
rundowns: { ...existing.rundowns, ...rundowns },
|
||||||
project: { ...deepExisting.project, ...project },
|
project: { ...deepExisting.project, ...project },
|
||||||
settings: { ...deepExisting.settings, ...settings },
|
settings: { ...deepExisting.settings, ...settings },
|
||||||
viewSettings: { ...deepExisting.viewSettings, ...viewSettings },
|
viewSettings: { ...deepExisting.viewSettings, ...viewSettings },
|
||||||
|
|||||||
@@ -1,31 +1,6 @@
|
|||||||
import type { IncomingMessage } from 'node:http';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
import { isPublicAssetRequest } from '../authenticate.js';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
vi.mock('../../api-data/session/session.service.js', () => ({
|
|
||||||
hasPassword: true,
|
|
||||||
hashedPassword: 'valid-token',
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { authenticateSocket, isPublicAssetRequest, makeAuthenticateMiddleware } from '../authenticate.js';
|
|
||||||
|
|
||||||
function makeResponse() {
|
|
||||||
return {
|
|
||||||
redirect: vi.fn(),
|
|
||||||
send: vi.fn(),
|
|
||||||
status: vi.fn().mockReturnThis(),
|
|
||||||
} as unknown as Response;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeHeadersWithFailingAuthorization(cookie?: string) {
|
|
||||||
return {
|
|
||||||
cookie,
|
|
||||||
get authorization(): never {
|
|
||||||
throw new Error('Authorization header should not be read');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('isPublicAssetRequest()', () => {
|
describe('isPublicAssetRequest()', () => {
|
||||||
it('allows root public assets without a prefix', () => {
|
it('allows root public assets without a prefix', () => {
|
||||||
@@ -43,144 +18,3 @@ describe('isPublicAssetRequest()', () => {
|
|||||||
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
|
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('bearer authentication', () => {
|
|
||||||
const next = vi.fn() as NextFunction;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
next.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prioritises cookie authentication for API requests', () => {
|
|
||||||
const { authenticate } = makeAuthenticateMiddleware('');
|
|
||||||
const req = {
|
|
||||||
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
|
||||||
headers: makeHeadersWithFailingAuthorization(),
|
|
||||||
query: {},
|
|
||||||
} as unknown as Request;
|
|
||||||
|
|
||||||
expect(() => authenticate(req, makeResponse(), next)).not.toThrow();
|
|
||||||
expect(next).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prioritises cookie authentication for redirecting routes', () => {
|
|
||||||
const { authenticateAndRedirect } = makeAuthenticateMiddleware('');
|
|
||||||
const req = {
|
|
||||||
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
|
||||||
headers: makeHeadersWithFailingAuthorization(),
|
|
||||||
originalUrl: '/external/image.png',
|
|
||||||
query: {},
|
|
||||||
} as unknown as Request;
|
|
||||||
|
|
||||||
expect(() => authenticateAndRedirect(req, makeResponse(), next)).not.toThrow();
|
|
||||||
expect(next).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prioritises cookie authentication for WebSocket handshakes', () => {
|
|
||||||
const cookie = `token=${encodeURIComponent(JSON.stringify({ token: 'valid-token' }))}`;
|
|
||||||
const req = { headers: makeHeadersWithFailingAuthorization(cookie) } as IncomingMessage;
|
|
||||||
|
|
||||||
expect(() => authenticateSocket({} as never, req, next)).not.toThrow();
|
|
||||||
expect(next).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('authenticates API requests with a bearer token', () => {
|
|
||||||
const { authenticate } = makeAuthenticateMiddleware('');
|
|
||||||
const req = {
|
|
||||||
cookies: {},
|
|
||||||
headers: { authorization: 'Bearer valid-token' },
|
|
||||||
query: {},
|
|
||||||
} as unknown as Request;
|
|
||||||
const res = makeResponse();
|
|
||||||
|
|
||||||
authenticate(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalledOnce();
|
|
||||||
expect(res.status).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts case-insensitive bearer schemes and extra whitespace', () => {
|
|
||||||
const { authenticate } = makeAuthenticateMiddleware('');
|
|
||||||
const req = {
|
|
||||||
cookies: {},
|
|
||||||
headers: { authorization: 'bearer valid-token ' },
|
|
||||||
query: {},
|
|
||||||
} as unknown as Request;
|
|
||||||
|
|
||||||
authenticate(req, makeResponse(), next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('authenticates redirecting routes with a bearer token', () => {
|
|
||||||
const { authenticateAndRedirect } = makeAuthenticateMiddleware('/stage');
|
|
||||||
const req = {
|
|
||||||
cookies: {},
|
|
||||||
headers: { authorization: 'Bearer valid-token' },
|
|
||||||
originalUrl: '/stage/external/image.png',
|
|
||||||
query: {},
|
|
||||||
} as unknown as Request;
|
|
||||||
const res = makeResponse();
|
|
||||||
|
|
||||||
authenticateAndRedirect(req, res, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalledOnce();
|
|
||||||
expect(res.redirect).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('authenticates WebSocket handshakes with a bearer token', () => {
|
|
||||||
const req = {
|
|
||||||
headers: { authorization: 'Bearer valid-token' },
|
|
||||||
} as IncomingMessage;
|
|
||||||
|
|
||||||
authenticateSocket({} as never, req, next);
|
|
||||||
|
|
||||||
expect(next).toHaveBeenCalledOnce();
|
|
||||||
expect(next).toHaveBeenCalledWith();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an invalid bearer token', () => {
|
|
||||||
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware('');
|
|
||||||
const req = {
|
|
||||||
cookies: {},
|
|
||||||
headers: { authorization: 'Bearer invalid-token' },
|
|
||||||
query: {},
|
|
||||||
} as unknown as Request;
|
|
||||||
const res = makeResponse();
|
|
||||||
|
|
||||||
authenticate(req, res, next);
|
|
||||||
|
|
||||||
expect(next).not.toHaveBeenCalled();
|
|
||||||
expect(res.status).toHaveBeenCalledWith(401);
|
|
||||||
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
|
||||||
|
|
||||||
const redirectReq = { ...req, originalUrl: '/external/image.png' } as Request;
|
|
||||||
const redirectRes = makeResponse();
|
|
||||||
authenticateAndRedirect(redirectReq, redirectRes, next);
|
|
||||||
|
|
||||||
expect(next).not.toHaveBeenCalled();
|
|
||||||
expect(redirectRes.redirect).toHaveBeenCalledWith('/login?redirect=/external/image.png');
|
|
||||||
|
|
||||||
const socketNext = vi.fn();
|
|
||||||
authenticateSocket(
|
|
||||||
{} as never,
|
|
||||||
{ headers: { authorization: 'Bearer invalid-token' } } as IncomingMessage,
|
|
||||||
socketNext,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(socketNext).toHaveBeenCalledOnce();
|
|
||||||
expect(socketNext.mock.calls[0][0]).toEqual(new Error('Unauthorized'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['/socket?not_token=valid-token', '/socket?token=valid-token-suffix'])(
|
|
||||||
'rejects lookalike WebSocket query tokens in %s',
|
|
||||||
(url) => {
|
|
||||||
const socketNext = vi.fn();
|
|
||||||
|
|
||||||
authenticateSocket({} as never, { headers: { host: 'localhost' }, url } as IncomingMessage, socketNext);
|
|
||||||
|
|
||||||
expect(socketNext).toHaveBeenCalledOnce();
|
|
||||||
expect(socketNext.mock.calls[0][0]).toEqual(new Error('Unauthorized'));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -77,17 +77,18 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
|||||||
const loginRedirectBase = `${prefix}/login?redirect=`;
|
const loginRedirectBase = `${prefix}/login?redirect=`;
|
||||||
|
|
||||||
function authenticate(req: Request, res: Response, next: NextFunction) {
|
function authenticate(req: Request, res: Response, next: NextFunction) {
|
||||||
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
if (req.query.token) {
|
||||||
|
if (req.query.token === hashedPassword) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
|
||||||
return next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getTokenFromParams(req.query) === hashedPassword) {
|
if (req.cookies?.token) {
|
||||||
|
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
||||||
|
if (tokenFromCookie === hashedPassword) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.status(401).send('Unauthorized');
|
res.status(401).send('Unauthorized');
|
||||||
}
|
}
|
||||||
@@ -104,17 +105,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
// we expect the token to be in the cookies
|
||||||
|
if (req.cookies?.token) {
|
||||||
|
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
||||||
|
if (tokenFromCookie === hashedPassword) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
|
||||||
return next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// we use query params for generating authenticated URLs and for clients like the companion module
|
// we use query params for generating authenticated URLs and for clients like the companion module
|
||||||
// if the user gives is a token in the query params, we set the cookie to be used in further requests
|
// if the user gives is a token in the query params, we set the cookie to be used in further requests
|
||||||
if (getTokenFromParams(req.query) === hashedPassword) {
|
if (req.query.token === hashedPassword) {
|
||||||
if (hashedPassword !== undefined) {
|
if (hashedPassword !== undefined) {
|
||||||
setSessionCookie(res, hashedPassword, prefix);
|
setSessionCookie(res, hashedPassword, prefix);
|
||||||
}
|
}
|
||||||
@@ -135,16 +136,33 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getTokenFromCookies(req.headers.cookie) === hashedPassword) {
|
// check if the token is in the cookie
|
||||||
|
const cookieString = req.headers.cookie;
|
||||||
|
if (typeof cookieString === 'string') {
|
||||||
|
const cookies = parseCookie(cookieString);
|
||||||
|
if (cookies.token) {
|
||||||
|
const token = getTokenFromCookie(cookies.token);
|
||||||
|
if (token === hashedPassword) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if token is in the params - simple string check first
|
||||||
|
const urlString = req.url || '';
|
||||||
|
if (urlString.includes(`token=${hashedPassword}`)) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
// fallback to full URL parsing for other formats
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString, `http://${req.headers.host}`);
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
if (token === hashedPassword) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
} catch (_) {
|
||||||
if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) {
|
// ignore URL parsing errors
|
||||||
return next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return next(new Error('Unauthorized'));
|
return next(new Error('Unauthorized'));
|
||||||
@@ -163,18 +181,19 @@ function setSessionCookie(res: Response, token: string, prefix: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTokenFromCookies(cookies: string | Record<string, unknown> | undefined): string | undefined {
|
/**
|
||||||
const cookieContents = typeof cookies === 'string' ? parseCookie(cookies).token : cookies?.token;
|
* When calling this function we already know a cookie called 'token' exists
|
||||||
if (typeof cookieContents !== 'string') {
|
* And want to extract its value
|
||||||
return undefined;
|
*/
|
||||||
}
|
function getTokenFromCookie(cookieContents: string): string | undefined {
|
||||||
|
// Fast path: check if the hashed password is directly in the cookie string
|
||||||
// Fast path: avoid JSON parsing when the expected token can be found directly
|
// This avoids JSON parsing for the common case
|
||||||
const cookieTokenString = '"token":"' + hashedPassword + '"';
|
const cookieTokenString = '"token":"' + hashedPassword + '}"';
|
||||||
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
|
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
|
||||||
return hashedPassword;
|
return hashedPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback to JSON parsing for other cases or validation
|
||||||
try {
|
try {
|
||||||
const cookie = JSON.parse(cookieContents);
|
const cookie = JSON.parse(cookieContents);
|
||||||
if (cookie && typeof cookie.token === 'string') {
|
if (cookie && typeof cookie.token === 'string') {
|
||||||
@@ -184,19 +203,3 @@ function getTokenFromCookies(cookies: string | Record<string, unknown> | undefin
|
|||||||
// no error handling to do here
|
// no error handling to do here
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTokenFromAuthHeader(authorization: string | undefined): string | undefined {
|
|
||||||
return authorization?.match(/^Bearer\s+(\S+)\s*$/i)?.[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTokenFromParams(params: string | Record<string, unknown> | undefined, host?: string): string | undefined {
|
|
||||||
if (typeof params !== 'string') {
|
|
||||||
return typeof params?.token === 'string' ? params.token : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return new URL(params, `http://${host}`).searchParams.get('token') ?? undefined;
|
|
||||||
} catch (_) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
import { Offset, OffsetMode, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types';
|
|
||||||
|
|
||||||
import { makeOntimeEvent, makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
|
|
||||||
import {
|
|
||||||
findNextPlayableId,
|
|
||||||
findNextPlayableWithCue,
|
|
||||||
findPreviousPlayableId,
|
|
||||||
getEventAtIndex,
|
|
||||||
getShouldClockUpdate,
|
|
||||||
getShouldOffsetUpdate,
|
|
||||||
getShouldTimerUpdate,
|
|
||||||
isNewSecond,
|
|
||||||
} from '../runtime.utils.js';
|
|
||||||
|
|
||||||
describe('isNewSecond()', () => {
|
|
||||||
it('is false while the value moves within the same second', () => {
|
|
||||||
// count down rounds up, so both resolve to second 2
|
|
||||||
expect(isNewSecond(1500, 1200)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is true once the value crosses a second boundary', () => {
|
|
||||||
expect(isNewSecond(1001, 1000)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rounds according to the given direction', () => {
|
|
||||||
// 1200 -> ceil 2 / floor 1, 1800 -> ceil 2 / floor 1
|
|
||||||
expect(isNewSecond(1200, 1800, TimerType.CountDown)).toBe(false);
|
|
||||||
expect(isNewSecond(1200, 1800, TimerType.CountUp)).toBe(false);
|
|
||||||
// 1200 -> ceil 2 / floor 1, 2200 -> ceil 3 / floor 2
|
|
||||||
expect(isNewSecond(1200, 2200, TimerType.CountDown)).toBe(true);
|
|
||||||
expect(isNewSecond(1200, 2200, TimerType.CountUp)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats null and undefined as second zero', () => {
|
|
||||||
expect(isNewSecond(undefined, null)).toBe(false);
|
|
||||||
expect(isNewSecond(null, 0)).toBe(false);
|
|
||||||
expect(isNewSecond(undefined, 500)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getShouldClockUpdate()', () => {
|
|
||||||
it('is false within the same second and true across the boundary', () => {
|
|
||||||
expect(getShouldClockUpdate(1000, 1999)).toBe(false);
|
|
||||||
expect(getShouldClockUpdate(1000, 2000)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getShouldTimerUpdate()', () => {
|
|
||||||
const baseTimer: TimerState = {
|
|
||||||
addedTime: 0,
|
|
||||||
current: 10000,
|
|
||||||
duration: 10000,
|
|
||||||
elapsed: 0,
|
|
||||||
expectedFinish: 10000,
|
|
||||||
phase: TimerPhase.Default,
|
|
||||||
playback: Playback.Play,
|
|
||||||
secondaryTimer: null,
|
|
||||||
startedAt: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
it('always updates when there is no previous state', () => {
|
|
||||||
expect(getShouldTimerUpdate(undefined, baseTimer)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not update while the timer ticks within the same second', () => {
|
|
||||||
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, current: 9500 })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates when the timer crosses a second', () => {
|
|
||||||
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, current: 8999 })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates when the secondary timer crosses a second', () => {
|
|
||||||
const previous = { ...baseTimer, secondaryTimer: 2000 };
|
|
||||||
// counting down rounds up, so 1999 is still second 2
|
|
||||||
expect(getShouldTimerUpdate(previous, { ...previous, secondaryTimer: 1999 })).toBe(false);
|
|
||||||
expect(getShouldTimerUpdate(previous, { ...previous, secondaryTimer: 1000 })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['addedTime', { addedTime: 1 }],
|
|
||||||
['duration', { duration: 1 }],
|
|
||||||
['phase', { phase: TimerPhase.Warning }],
|
|
||||||
['playback', { playback: Playback.Pause }],
|
|
||||||
['startedAt', { startedAt: 1 }],
|
|
||||||
])('updates immediately when %s changes', (_label, patch) => {
|
|
||||||
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, ...patch })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['elapsed', { elapsed: 1 }],
|
|
||||||
['expectedFinish', { expectedFinish: 1 }],
|
|
||||||
])('does not update on %s alone, since it is derived', (_label, patch) => {
|
|
||||||
expect(getShouldTimerUpdate(baseTimer, { ...baseTimer, ...patch })).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getShouldOffsetUpdate()', () => {
|
|
||||||
const baseOffset: Offset = {
|
|
||||||
absolute: 0,
|
|
||||||
relative: 0,
|
|
||||||
mode: OffsetMode.Absolute,
|
|
||||||
expectedGroupEnd: null,
|
|
||||||
expectedRundownEnd: null,
|
|
||||||
expectedFlagStart: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
it('always updates when there is no previous state', () => {
|
|
||||||
expect(getShouldOffsetUpdate(undefined, baseOffset, false)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates on a mode change even when no dependency ticked', () => {
|
|
||||||
expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset, mode: OffsetMode.Relative }, false)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('holds back value changes until a dependency ticks', () => {
|
|
||||||
const next = { ...baseOffset, absolute: 1000 };
|
|
||||||
expect(getShouldOffsetUpdate(baseOffset, next, false)).toBe(false);
|
|
||||||
expect(getShouldOffsetUpdate(baseOffset, next, true)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not update when a dependency ticked but nothing changed', () => {
|
|
||||||
expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset }, true)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('findPreviousPlayableId()', () => {
|
|
||||||
const order = ['1', '2', '3'];
|
|
||||||
|
|
||||||
it('returns undefined when there is nothing to play', () => {
|
|
||||||
expect(findPreviousPlayableId([])).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the first event when nothing is loaded', () => {
|
|
||||||
expect(findPreviousPlayableId(order)).toBe('1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the preceding event', () => {
|
|
||||||
expect(findPreviousPlayableId(order, '3')).toBe('2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stays on the first event when already at the top', () => {
|
|
||||||
expect(findPreviousPlayableId(order, '1')).toBe('1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the first event when the loaded id is unknown', () => {
|
|
||||||
expect(findPreviousPlayableId(order, 'not-in-rundown')).toBe('1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('findNextPlayableId()', () => {
|
|
||||||
const order = ['1', '2', '3'];
|
|
||||||
|
|
||||||
it('returns undefined when there is nothing to play', () => {
|
|
||||||
expect(findNextPlayableId([])).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the first event when nothing is loaded', () => {
|
|
||||||
expect(findNextPlayableId(order)).toBe('1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the following event', () => {
|
|
||||||
expect(findNextPlayableId(order, '1')).toBe('2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wraps to the first event from the last', () => {
|
|
||||||
expect(findNextPlayableId(order, '3')).toBe('1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the first event when the loaded id is unknown', () => {
|
|
||||||
expect(findNextPlayableId(order, 'not-in-rundown')).toBe('1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('findNextPlayableWithCue()', () => {
|
|
||||||
const rundown = makeRundown({
|
|
||||||
order: ['1', '2', '3', '4'],
|
|
||||||
entries: {
|
|
||||||
'1': makeOntimeEvent({ id: '1', cue: 'a' }),
|
|
||||||
'2': makeOntimeEvent({ id: '2', cue: 'b' }),
|
|
||||||
'3': makeOntimeEvent({ id: '3', cue: 'b', skip: true }),
|
|
||||||
'4': makeOntimeEvent({ id: '4', cue: 'b' }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const order = ['1', '2', '3', '4'];
|
|
||||||
|
|
||||||
it('finds the next event with the given cue', () => {
|
|
||||||
expect(findNextPlayableWithCue(rundown, order, 'b')?.id).toBe('2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips events which are not playable', () => {
|
|
||||||
expect(findNextPlayableWithCue(rundown, order, 'b', 2)?.id).toBe('4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wraps around to the start of the rundown', () => {
|
|
||||||
expect(findNextPlayableWithCue(rundown, order, 'a', 2)?.id).toBe('1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('excludes the current event unless allowCurrent is set', () => {
|
|
||||||
expect(findNextPlayableWithCue(rundown, order, 'b', 1)?.id).toBe('4');
|
|
||||||
expect(findNextPlayableWithCue(rundown, order, 'b', 1, true)?.id).toBe('2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined when no event carries the cue', () => {
|
|
||||||
expect(findNextPlayableWithCue(rundown, order, 'missing')).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getEventAtIndex()', () => {
|
|
||||||
const rundown = makeRundown({
|
|
||||||
order: ['1', '2'],
|
|
||||||
entries: {
|
|
||||||
'1': makeOntimeEvent({ id: '1' }),
|
|
||||||
'2': makeOntimeEvent({ id: '2' }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the event at the given index', () => {
|
|
||||||
expect(getEventAtIndex(rundown, ['1', '2'], 1)?.id).toBe('2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined when the index is out of range', () => {
|
|
||||||
expect(getEventAtIndex(rundown, ['1', '2'], 5)).toBeUndefined();
|
|
||||||
expect(getEventAtIndex(rundown, [], 0)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -20,7 +20,6 @@ import { triggerAutomations } from '../../api-data/automation/automation.service
|
|||||||
import { triggerReportEntry } from '../../api-data/report/report.service.js';
|
import { triggerReportEntry } from '../../api-data/report/report.service.js';
|
||||||
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
|
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';
|
||||||
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
||||||
import { cloneEntryData } from '../../api-data/rundown/rundown.utils.js';
|
|
||||||
import { logger } from '../../classes/Logger.js';
|
import { logger } from '../../classes/Logger.js';
|
||||||
import { timerConfig } from '../../setup/config.js';
|
import { timerConfig } from '../../setup/config.js';
|
||||||
import { eventStore } from '../../stores/EventStore.js';
|
import { eventStore } from '../../stores/EventStore.js';
|
||||||
@@ -755,7 +754,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
|||||||
}
|
}
|
||||||
// at this point we know that either the id or the contents has changed
|
// at this point we know that either the id or the contents has changed
|
||||||
batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this
|
batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this
|
||||||
RuntimeService.previousState[key] = currentEntry ? cloneEntryData(currentEntry) : null;
|
RuntimeService.previousState[key] = structuredClone(currentEntry);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
# Ontime architecture
|
|
||||||
|
|
||||||
Use for module placement or cross-boundary changes.
|
|
||||||
|
|
||||||
## Direction
|
|
||||||
|
|
||||||
Dependencies point toward pure domain logic:
|
|
||||||
|
|
||||||
```text
|
|
||||||
HTTP request
|
|
||||||
-> validation / router or controller
|
|
||||||
-> service orchestration
|
|
||||||
-> pure domain utilities
|
|
||||||
|
|
||||||
service orchestration
|
|
||||||
-> DAO / stores / adapters / external clients
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep a layer only for clearer ownership, isolated side effects, or direct business-rule tests.
|
|
||||||
|
|
||||||
## Reuse and ownership
|
|
||||||
|
|
||||||
Before adding a module, helper, service, or interface, find the concept owner and inspect callers.
|
|
||||||
|
|
||||||
1. Reuse when the contract already matches.
|
|
||||||
2. Extend for the same concept when the contract stays coherent.
|
|
||||||
3. Keep local when reuse needs flags, broad optional inputs, unrelated modes, or leaky terms.
|
|
||||||
4. Generalise only after stable common behaviour appears across real callers.
|
|
||||||
|
|
||||||
Do not duplicate canonical rules or distort an abstraction to force reuse. Small local duplication can beat coupling unrelated concepts.
|
|
||||||
|
|
||||||
## Server
|
|
||||||
|
|
||||||
### Routers and controllers
|
|
||||||
|
|
||||||
Routers declare paths and middleware. Controllers map validated HTTP input to typed service arguments, then results/errors to responses.
|
|
||||||
|
|
||||||
Keep reusable calculations, domain decisions, transformations, persistence workflows, and integration coordination out of handlers. Simple reads may stay direct when a service would only pass through.
|
|
||||||
|
|
||||||
### Services
|
|
||||||
|
|
||||||
Services orchestrate use cases and side-effect boundaries. Make ordering and effects visible. Move substantial branching, calculation, comparison, parsing, and transformation to pure utilities.
|
|
||||||
|
|
||||||
### Pure utilities
|
|
||||||
|
|
||||||
Pass required state, config, and time explicitly. No I/O, stores/globals, logging, websocket publication, browser inspection, or caller-owned mutation unless explicitly contracted.
|
|
||||||
|
|
||||||
Use focused Vitest coverage. Colocate feature logic. Move to `ontime-utils` only for genuine cross-package use.
|
|
||||||
|
|
||||||
### State and boundaries
|
|
||||||
|
|
||||||
- DAOs/data providers: persistence.
|
|
||||||
- Stores: mutable runtime state.
|
|
||||||
- Adapters/clients: external protocols and integrations.
|
|
||||||
- Validators/parsers: protect boundaries before domain logic.
|
|
||||||
- Commit state before dependent notifications or invalidations.
|
|
||||||
|
|
||||||
Old layering exceptions are context, not precedent. Improve touched boundaries only through focused, behaviour-preserving moves.
|
|
||||||
|
|
||||||
## Client
|
|
||||||
|
|
||||||
- `common/api`: HTTP transport.
|
|
||||||
- `common/hooks-query`: TanStack Query reads, mutations, keys, cache, invalidation.
|
|
||||||
- `features`: reusable product capabilities and domain behaviour.
|
|
||||||
- `views`: route-level composition.
|
|
||||||
- `common`: genuinely cross-feature code.
|
|
||||||
|
|
||||||
Keep substantial rules out of JSX, effects, and handlers. Use tested, colocated pure utilities. TanStack Query owns server state; established Zustand/context owns local state. No parallel caches.
|
|
||||||
|
|
||||||
Limit subscriptions with selectors. Keep effect dependencies stable. Clean up listeners, intervals, external resources.
|
|
||||||
|
|
||||||
## Shared packages
|
|
||||||
|
|
||||||
- `ontime-types`: shared contracts; type-focused.
|
|
||||||
- `ontime-utils`: environment-independent, side-effect-free shared logic.
|
|
||||||
- Never import application layers into shared packages.
|
|
||||||
- Keep feature-specific helpers with their owner, even when used by another file.
|
|
||||||
|
|
||||||
## Review prompts
|
|
||||||
|
|
||||||
- Business rule understandable/testable without app startup?
|
|
||||||
- Transport, orchestration, state, transformation separated?
|
|
||||||
- Existing owner reused without forcing unrelated behaviour?
|
|
||||||
- Abstraction removes concepts rather than relocating them?
|
|
||||||
- Smallest focused remedy clear?
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Change assessment
|
|
||||||
|
|
||||||
Use before non-trivial planning/implementation and during non-trivial review. Let it shape scope and verification; avoid process theatre.
|
|
||||||
|
|
||||||
## Rate three dimensions
|
|
||||||
|
|
||||||
- **Value** — concrete user, product, operational, or maintenance benefit; include urgency.
|
|
||||||
- **Risk** — regression likelihood/impact: data loss, security, cloud incompatibility, disruption, hard rollback.
|
|
||||||
- **Complexity** — concepts, dependencies, layers, states, verification surfaces; not line count.
|
|
||||||
|
|
||||||
Use `Low`, `Medium`, or `High`. Give one evidence-based sentence each. No pseudo-precise scores.
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Assessment
|
|
||||||
|
|
||||||
- Value: High — <concrete benefit>
|
|
||||||
- Risk: Medium — <failure modes and reversibility>
|
|
||||||
- Complexity: Low — <conceptual and verification burden>
|
|
||||||
- Recommendation: Proceed | Reshape | Defer — <why>
|
|
||||||
```
|
|
||||||
|
|
||||||
One assessment per overall change.
|
|
||||||
|
|
||||||
- High value never excuses unmanaged risk/complexity.
|
|
||||||
- High risk needs earlier proof, narrower increments, rollback, or stronger checks.
|
|
||||||
- High complexity needs clearer boundaries and smaller steps, not automatic abstraction.
|
|
||||||
- Low value plus high risk/complexity suggests reshape or defer.
|
|
||||||
- Revise after material scope discovery.
|
|
||||||
|
|
||||||
Plans: assessment before steps. Reviews: findings first, then assessment. Assessment informs; never replaces user intent or evidence.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# Simplicity and maintainability
|
|
||||||
|
|
||||||
Use for abstractions, comments, helpers, naming, or structural complexity.
|
|
||||||
|
|
||||||
## Simplicity
|
|
||||||
|
|
||||||
Choose the smallest explicit, testable design. No extension points, generic engines, wrappers, or config for hypothetical needs.
|
|
||||||
|
|
||||||
- Prefer direct flow over clever expressions or scattered conditions.
|
|
||||||
- Extract only to name a rule, enable pure tests, or remove meaningful duplication.
|
|
||||||
- Keep abstractions only when they reduce reader-held concepts.
|
|
||||||
- Reuse the concept owner when semantics match.
|
|
||||||
- Do not add flags, optional branches, generic names, or extension points to merge unlike cases.
|
|
||||||
- Prefer focused local helpers over generic APIs exposing unrelated modes.
|
|
||||||
- Keep scope narrow; note unrelated cleanup.
|
|
||||||
- Delete clearly obsolete branches, harnesses, and shims. Ask when ownership or compatibility is unclear.
|
|
||||||
|
|
||||||
## Comments
|
|
||||||
|
|
||||||
Keep comments for:
|
|
||||||
|
|
||||||
- non-obvious intent or invariants;
|
|
||||||
- necessary ordering, timing, mutation, or side effects;
|
|
||||||
- browser, Electron, cloud, or protocol constraints;
|
|
||||||
- workaround reasons and removal conditions;
|
|
||||||
- public contracts names/types cannot express.
|
|
||||||
|
|
||||||
Remove comments that:
|
|
||||||
|
|
||||||
- narrate code or names;
|
|
||||||
- number obvious steps;
|
|
||||||
- explain mechanics better expressed by code;
|
|
||||||
- preserve removed history;
|
|
||||||
- claim untested behaviour;
|
|
||||||
- use banners to hide oversized modules.
|
|
||||||
|
|
||||||
Update adjacent comments with code. Stale comments are defects.
|
|
||||||
|
|
||||||
## Naming and types
|
|
||||||
|
|
||||||
- Prefer Ontime terms over vague `data`, `result`, `item`.
|
|
||||||
- Prefer explicit/discriminated types over `any`, broad casts, optional fields, non-null assertions, silent fallbacks.
|
|
||||||
- Handle unions/enums exhaustively when future cases could be unsafe.
|
|
||||||
- Keep public functions focused enough to avoid long contract explanations.
|
|
||||||
|
|
||||||
## Review standard
|
|
||||||
|
|
||||||
Do not demand personal style. Report complexity only when it risks maintenance, hides rules, blocks focused tests, duplicates ownership, or makes changes unsafe. Suggest reuse only after verifying the existing contract fits without added genericity.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# Ontime domain invariants
|
|
||||||
|
|
||||||
Load only for touched domains. Add only stable, recurring invariants; not one-off bugs.
|
|
||||||
|
|
||||||
## Rundowns and entries
|
|
||||||
|
|
||||||
- Keep `entries`, `order`, `flatOrder` normalised.
|
|
||||||
- Keep group membership, group entry lists, child `parent` references consistent.
|
|
||||||
- Preserve entry identity and supported types across patch, clone, group, ungroup, reorder.
|
|
||||||
- Distinguish loaded vs background rundown. Prefer explicit rundown ID over global current state.
|
|
||||||
- No caller-owned rundown mutation unless explicitly contracted.
|
|
||||||
|
|
||||||
## Persistence, realtime, cache
|
|
||||||
|
|
||||||
- No partial commit on failure.
|
|
||||||
- Preserve revision/transaction semantics for loaded and background rundowns.
|
|
||||||
- Persist before websocket refetches, runtime updates, integration notifications, or cache assumptions.
|
|
||||||
- Notify only invalidated consumers; never leave client cache stale.
|
|
||||||
- Avoid duplicate listeners, notifications, invalidations, lifecycle effects.
|
|
||||||
- Reconnect/refetch must converge on authoritative state.
|
|
||||||
- Align query keys and websocket refetch keys with the changed resource.
|
|
||||||
|
|
||||||
## Timers
|
|
||||||
|
|
||||||
Use temporal values by meaning: `Instant` for epoch time, `TimeOfDay` for local time since midnight, `Duration` for elapsed time, `Day` for calendar offsets. Convert through `timeCore`; never interchange as raw numbers.
|
|
||||||
|
|
||||||
Active work: [runtimeState time-core migration](../migrations/runtime-state-time-core.md).
|
|
||||||
|
|
||||||
When relevant, cover interactions among:
|
|
||||||
|
|
||||||
- midnight/day offsets;
|
|
||||||
- linked events/gaps;
|
|
||||||
- delays/skipped entries;
|
|
||||||
- count-to-end;
|
|
||||||
- absolute/relative offsets;
|
|
||||||
- warning, danger, finish, roll, end-action transitions;
|
|
||||||
- loaded/next-event state.
|
|
||||||
|
|
||||||
Pass time/state explicitly to keep rules deterministic and unit-testable.
|
|
||||||
|
|
||||||
## Imports and migrations
|
|
||||||
|
|
||||||
- Treat project files, spreadsheets, custom fields, migrated data as untrusted.
|
|
||||||
- Preserve fields the import/migration does not own.
|
|
||||||
- Validate/parse into the current model before runtime logic.
|
|
||||||
- Avoid source mutation; test round trips and non-mutation when preservation matters.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# Routing and Ontime Cloud
|
|
||||||
|
|
||||||
Use for navigation, URLs, endpoints, websockets, auth, cookies, assets, redirects, presets, or local-storage scope.
|
|
||||||
|
|
||||||
## Deployment invariant
|
|
||||||
|
|
||||||
Support root and runtime-prefixed deployments:
|
|
||||||
|
|
||||||
```text
|
|
||||||
local: http://localhost:4001/timer
|
|
||||||
cloud: https://cloud.example/client-hash/timer
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefix is deployment data. Never assume `/`.
|
|
||||||
|
|
||||||
## Client
|
|
||||||
|
|
||||||
- `apps/client/src/externals.ts`: derives `baseURI`, `serverURL`, `websocketUrl` from document base/current origin.
|
|
||||||
- `BrowserRouter`: uses `baseURI` basename.
|
|
||||||
- APIs/assets: use `common/api/constants.ts` or base-aware helpers.
|
|
||||||
- App navigation: use React Router. Never strip/guess/re-add prefix from `window.location.pathname`.
|
|
||||||
- Persisted browser state: use existing base-aware scoping where prefixes need isolation.
|
|
||||||
|
|
||||||
## Server
|
|
||||||
|
|
||||||
- `updateRouterPrefix()` in `apps/server/src/externals.ts`: normalises `ROUTER_PREFIX`.
|
|
||||||
- `apps/server/src/app.ts`: mounts routes below that prefix.
|
|
||||||
- Domain routers: paths relative to mount; never derive prefix.
|
|
||||||
- Websockets, auth redirects, cookie paths, share URLs: preserve prefix.
|
|
||||||
|
|
||||||
## URL rules
|
|
||||||
|
|
||||||
Use `URL`, React Router, or existing helpers instead of string manipulation. Preserve:
|
|
||||||
|
|
||||||
- leading/trailing slashes and runtime prefix;
|
|
||||||
- query params, auth tokens, navigation locks;
|
|
||||||
- preset aliases and canonical view paths;
|
|
||||||
- `https`/`wss` behind proxies;
|
|
||||||
- static/user asset paths.
|
|
||||||
|
|
||||||
Never infer Ontime Cloud from hostname alone. Generated base markup marks cloud; runtime prefixes also serve non-cloud reverse proxies.
|
|
||||||
|
|
||||||
## Cloud capabilities
|
|
||||||
|
|
||||||
Gate unavailable local-network integrations in cloud, including OSC output. Keep server behaviour and UI availability aligned.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Test both root and a prefix such as `/client-hash`. Include relevant queries, redirects, cookies, websocket paths, presets, assets. Root-only routing coverage is incomplete.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Security boundaries
|
|
||||||
|
|
||||||
Use for auth, external input, files, integrations, assets, URLs, or secrets.
|
|
||||||
|
|
||||||
## Untrusted inputs
|
|
||||||
|
|
||||||
Validate/parse before trust:
|
|
||||||
|
|
||||||
- HTTP bodies, params, headers, cookies, websocket messages;
|
|
||||||
- project files, migrations, spreadsheets, imports;
|
|
||||||
- custom HTML/CSS/views, translations, served assets;
|
|
||||||
- automation payloads, third-party responses;
|
|
||||||
- externally supplied paths/filenames.
|
|
||||||
|
|
||||||
Validate shape and domain constraints at the owning boundary. Keep existing parser/validation layers; avoid downstream defensive casts.
|
|
||||||
|
|
||||||
## Authentication and authorisation
|
|
||||||
|
|
||||||
- Keep protected routers behind auth middleware in `apps/server/src/app.ts`.
|
|
||||||
- Preserve auth across prefixed routes, redirects, websockets, generated links.
|
|
||||||
- Scope session cookies to runtime prefix; isolate hosted clients.
|
|
||||||
- Authentication never grants arbitrary file, path, project, or rundown access.
|
|
||||||
|
|
||||||
## Files, URLs, integrations
|
|
||||||
|
|
||||||
- Use established path/file helpers. Reject traversal and unexpected types.
|
|
||||||
- Build URLs with `URL` or existing helpers. Check open redirects, SSRF, protocol changes, token leaks.
|
|
||||||
- Encode/constrain untrusted HTML, CSS, filenames, headers, log values.
|
|
||||||
- Preserve cloud limits on local-network capabilities.
|
|
||||||
|
|
||||||
## Secrets and diagnostics
|
|
||||||
|
|
||||||
- Never commit/log passwords, hashes, tokens, credentials, cookies, private project content.
|
|
||||||
- Errors: useful, but no internal paths, stacks, credentials, sensitive payloads.
|
|
||||||
- Keep tokens in existing session/authenticated-share flows. Avoid new URL-token patterns.
|
|
||||||
|
|
||||||
## Review prompts
|
|
||||||
|
|
||||||
- Where does input become trusted?
|
|
||||||
- Validation once at owner, then useful type?
|
|
||||||
- Can one prefixed client cross another client's session/assets?
|
|
||||||
- Can logs, responses, redirects, URLs leak secrets?
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Testing strategy
|
|
||||||
|
|
||||||
Use for changed behaviour or tests.
|
|
||||||
|
|
||||||
## Layers
|
|
||||||
|
|
||||||
### Pure unit
|
|
||||||
|
|
||||||
Put detailed business-rule coverage on pure functions. Test public inputs/outputs with Vitest: relevant boundaries, invalid input, ordering, rollover, errors.
|
|
||||||
|
|
||||||
Every bug fix needs a regression test that fails before the fix. Prefer behaviour over mocks/implementation assertions.
|
|
||||||
|
|
||||||
### Service and state
|
|
||||||
|
|
||||||
Use service, DAO, store, or hook tests for orchestration: transitions, transactions, persistence, cache, notifications, external effects.
|
|
||||||
|
|
||||||
Do not repeat all pure cases here. Prove delegation and sequencing.
|
|
||||||
|
|
||||||
### End-to-end
|
|
||||||
|
|
||||||
Reserve Playwright for key journeys and high-risk cross-layer integrations: edit/run rundown, playback, imports, rundown switching, cloud-prefixed navigation.
|
|
||||||
|
|
||||||
No E2E for edges already proven in lower layers. Add E2E only when lower layers cannot prove the user-facing integration.
|
|
||||||
|
|
||||||
## Compact before handoff
|
|
||||||
|
|
||||||
Development harnesses may be broad, repetitive, diagnostic, temporary. Before handoff:
|
|
||||||
|
|
||||||
1. Identify distinct required behaviours/regressions.
|
|
||||||
2. Keep the smallest readable set that catches them.
|
|
||||||
3. Parameterise repetition only when the table reads better.
|
|
||||||
4. Remove diagnostic assertions, redundant permutations, temporary fixtures, private-detail coupling.
|
|
||||||
5. Keep rare cases that encode real domain rules.
|
|
||||||
|
|
||||||
Optimise for future readers, not minimum line count.
|
|
||||||
|
|
||||||
## Quality
|
|
||||||
|
|
||||||
- Name tests by observable behaviour.
|
|
||||||
- Keep setup local/explicit unless a fixture improves comprehension.
|
|
||||||
- Avoid arbitrary waits, wall-clock dependence, cross-test state, weak assertions.
|
|
||||||
- Prefer realistic typed fixtures over large snapshots or masking casts.
|
|
||||||
- Test non-mutation when promised.
|
|
||||||
- No tests for trivial type/format changes or framework behaviour Ontime does not own.
|
|
||||||
|
|
||||||
## Review prompts
|
|
||||||
|
|
||||||
- Business logic directly unit-testable?
|
|
||||||
- Test catches the reported bug/regression?
|
|
||||||
- Cases distinct, not repeated path?
|
|
||||||
- Temporary harness leaked?
|
|
||||||
- E2E justified by cross-layer risk?
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Workflow and repository conventions
|
|
||||||
|
|
||||||
Use for commands, imports, formatting, CI, dependencies, PRs.
|
|
||||||
|
|
||||||
## Workspaces
|
|
||||||
|
|
||||||
Confirm names from `package.json`.
|
|
||||||
|
|
||||||
| Package | Workspace name |
|
|
||||||
| ---------------- | --------------------- |
|
|
||||||
| Client | `ontime-ui` |
|
|
||||||
| Server | `ontime-server` |
|
|
||||||
| Electron | `ontime-electron` |
|
|
||||||
| Resolver | `@getontime/resolver` |
|
|
||||||
| CLI | `@getontime/cli` |
|
|
||||||
| Shared types | `ontime-types` |
|
|
||||||
| Shared utilities | `ontime-utils` |
|
|
||||||
|
|
||||||
Run from repo root: `pnpm --filter <workspace-name> <script>`. Add/install workspaces only when required.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Start narrow; expand with risk:
|
|
||||||
|
|
||||||
```text
|
|
||||||
focused test
|
|
||||||
-> affected package tests
|
|
||||||
-> package lint/typecheck
|
|
||||||
-> required repository checks
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter ontime-ui test:pipeline <path-to-test>
|
|
||||||
pnpm --filter ontime-server test:pipeline <path-to-test>
|
|
||||||
pnpm --filter ontime-utils test:pipeline <path-to-test>
|
|
||||||
pnpm --filter <workspace-name> lint
|
|
||||||
pnpm --filter <workspace-name> typecheck
|
|
||||||
|
|
||||||
pnpm format:check
|
|
||||||
pnpm lint
|
|
||||||
pnpm typecheck
|
|
||||||
pnpm test
|
|
||||||
pnpm e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
PR CI: `.github/workflows/test.yml`; runs format, lint, types, unit, Playwright. Run E2E locally only for key flows or E2E infrastructure.
|
|
||||||
|
|
||||||
Before code commit: `pnpm lint`, `pnpm test`; add `pnpm typecheck` for TypeScript and `pnpm format:check` for formatted files. Claim only observed results.
|
|
||||||
|
|
||||||
## TypeScript and imports
|
|
||||||
|
|
||||||
- Strict TypeScript. Prefer `ontime-types` domain types over local copies.
|
|
||||||
- Keep client/server payloads aligned through `ontime-types`.
|
|
||||||
- Import `ontime-types`/`ontime-utils` from public entry points; Oxlint forbids `src` subpaths.
|
|
||||||
- Server relative ESM imports need `.js`.
|
|
||||||
- Reuse public exports/canonical helpers before adding utilities.
|
|
||||||
|
|
||||||
## Formatting and dependencies
|
|
||||||
|
|
||||||
- Use Oxlint/Oxfmt, not ESLint/Prettier.
|
|
||||||
- Oxfmt: two spaces, semicolons, single quotes, trailing commas, 120 columns.
|
|
||||||
- Add dependencies only when platform/current stack cannot solve the need. Review `package.json` and `pnpm-lock.yaml` together.
|
|
||||||
- Never hand-edit lockfile. No build output unless already tracked.
|
|
||||||
|
|
||||||
## Pull requests
|
|
||||||
|
|
||||||
- Title: `[<workspace-name>] <Title>`.
|
|
||||||
- Separate behaviour from unrelated refactors/format churn.
|
|
||||||
- State what changed, why, verification.
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# runtimeState time-core migration
|
|
||||||
|
|
||||||
**Status:** In progress
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Make temporal meaning explicit across `runtimeState` and timer calculations. Prevent mixing:
|
|
||||||
|
|
||||||
- `Instant`: epoch timestamp;
|
|
||||||
- `TimeOfDay`: local milliseconds since midnight, range `[0, dayInMs)`;
|
|
||||||
- `Duration`: elapsed or remaining milliseconds;
|
|
||||||
- `Day`: calendar-day offset.
|
|
||||||
|
|
||||||
Use `apps/server/src/lib/time-core/timeCore.ts` for conversions and temporal arithmetic. Branded types remain numbers at serialization boundaries.
|
|
||||||
|
|
||||||
## Current state
|
|
||||||
|
|
||||||
Completed foundation:
|
|
||||||
|
|
||||||
- temporal brands in `ontime-types`;
|
|
||||||
- `timeCore` helpers for now, conversion, duration arithmetic, midnight crossing, calendar-day distance;
|
|
||||||
- partial `runtimeState` adoption of `Instant`, `TimeOfDay`, `Day`, and `timeCore`.
|
|
||||||
|
|
||||||
Remaining ambiguity:
|
|
||||||
|
|
||||||
- `TimerState`, `RundownState`, and `Offset` expose temporal fields as `number`/`MaybeNumber`;
|
|
||||||
- `timerUtils` accepts/returns raw numbers with different meanings;
|
|
||||||
- `runtimeState` retains raw duration fields, manual arithmetic, and `as TimeOfDay`/`as Duration` casts.
|
|
||||||
|
|
||||||
## Migration rules
|
|
||||||
|
|
||||||
- Classify each temporal field before changing it. No generic `Time` type.
|
|
||||||
- Convert only through `timeCore` or an explicit transport adapter.
|
|
||||||
- Keep public/websocket JSON numeric where required; brand at the boundary.
|
|
||||||
- Keep type migration separate from behaviour changes.
|
|
||||||
- Preserve current midnight, rollover, pause, add-time, roll, and offset behaviour per slice.
|
|
||||||
- Add helpers only when they encode a named temporal rule and remove caller casts/arithmetic.
|
|
||||||
|
|
||||||
## Slices
|
|
||||||
|
|
||||||
- [x] Add temporal brands and initial `timeCore` helpers/tests.
|
|
||||||
- [ ] Inventory temporal fields in `TimerState`, `RundownState`, `Offset`, and private runtime state; assign intended types.
|
|
||||||
- [ ] Migrate pure `timerUtils` functions by temporal concept; add focused midnight/rollover tests.
|
|
||||||
- [ ] Migrate private `RuntimeState` fields and calculations; remove local casts/manual conversions.
|
|
||||||
- [ ] Migrate shared runtime contracts and add numeric transport adapters where compatibility requires them.
|
|
||||||
- [ ] Update remaining callers, fixtures, and mocks.
|
|
||||||
- [ ] Remove superseded helpers, casts, and ambiguous temporal numbers.
|
|
||||||
|
|
||||||
Each slice must leave old/new boundaries explicit, compile cleanly, and preserve behaviour.
|
|
||||||
|
|
||||||
## Required coverage
|
|
||||||
|
|
||||||
- before, at, and after midnight;
|
|
||||||
- overnight and multi-day rundowns;
|
|
||||||
- local-day calculation across timezone/DST offset changes;
|
|
||||||
- pause/resume, added time, elapsed/remaining duration;
|
|
||||||
- roll secondary targets and expected finish;
|
|
||||||
- absolute/relative offsets and day offsets.
|
|
||||||
|
|
||||||
## Complete when
|
|
||||||
|
|
||||||
- Runtime/timer boundaries use semantic temporal types or documented numeric transport fields.
|
|
||||||
- Temporal conversions/arithmetic use `timeCore` or named pure helpers.
|
|
||||||
- No unexplained temporal casts or ambiguous numeric fields remain in migrated scope.
|
|
||||||
- Focused tests cover the required boundaries.
|
|
||||||
|
|
||||||
After completion, remove this migration tracker. Keep durable rules in `docs/agent-guides/domain-invariants.md`.
|
|
||||||
@@ -48,47 +48,6 @@ test.describe('test view navigation feature', () => {
|
|||||||
await expect(page).toHaveURL('/timer');
|
await expect(page).toHaveURL('/timer');
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* The shortcut has to read the current open state, not the one captured when it was
|
|
||||||
* registered, otherwise it only ever opens the menu.
|
|
||||||
*/
|
|
||||||
test('Space closes the menu as well as opening it', async ({ page }) => {
|
|
||||||
const menu = page.getByRole('dialog');
|
|
||||||
await expect(menu).toBeHidden();
|
|
||||||
|
|
||||||
await openNavigationMenu(page);
|
|
||||||
await expect(menu).toBeVisible();
|
|
||||||
|
|
||||||
await page.keyboard.press('Space');
|
|
||||||
await expect(menu).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Space closes a menu opened with the button', async ({ page }) => {
|
|
||||||
await page.mouse.move(Math.random() * 100, Math.random() * 100);
|
|
||||||
await page.getByTestId('navigation__toggle-menu').click();
|
|
||||||
|
|
||||||
const menu = page.getByRole('dialog');
|
|
||||||
await expect(menu).toBeVisible();
|
|
||||||
|
|
||||||
await page.keyboard.press('Space');
|
|
||||||
await expect(menu).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Space toggles a focused menu switch without closing the menu', async ({ page }) => {
|
|
||||||
await openNavigationMenu(page);
|
|
||||||
|
|
||||||
const menu = page.getByRole('dialog');
|
|
||||||
const flipScreen = page.getByRole('switch', { name: 'Flip Screen' });
|
|
||||||
await expect(menu).toBeVisible();
|
|
||||||
|
|
||||||
const initiallyChecked = await flipScreen.getAttribute('aria-checked');
|
|
||||||
await flipScreen.focus();
|
|
||||||
await page.keyboard.press('Space');
|
|
||||||
|
|
||||||
await expect(flipScreen).toHaveAttribute('aria-checked', initiallyChecked === 'true' ? 'false' : 'true');
|
|
||||||
await expect(menu).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('not-found', async ({ page }) => {
|
test('not-found', async ({ page }) => {
|
||||||
await page.goto('/not-found');
|
await page.goto('/not-found');
|
||||||
|
|
||||||
|
|||||||
@@ -6,25 +6,6 @@ test('cuesheet displays events', async ({ page }) => {
|
|||||||
await expect(page.getByTestId('cuesheet-event').first()).toBeVisible();
|
await expect(page.getByTestId('cuesheet-event').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cuesheet persists column visibility', async ({ page }) => {
|
|
||||||
await page.goto('/cuesheet');
|
|
||||||
|
|
||||||
const noteHeader = page.getByRole('columnheader', { name: 'Note', exact: true });
|
|
||||||
const noteCell = page.getByTestId('cuesheet-event').first().getByTestId('cuesheet-cell-note');
|
|
||||||
await expect(noteHeader).toBeVisible();
|
|
||||||
await expect(noteCell).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Columns' }).click();
|
|
||||||
await page.getByRole('checkbox', { name: 'Note', exact: true }).click();
|
|
||||||
|
|
||||||
await expect(noteHeader).toBeHidden();
|
|
||||||
await expect(noteCell).toBeHidden();
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
await expect(noteHeader).toBeHidden();
|
|
||||||
await expect(noteCell).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cuesheet datagrid does not submit timer cells on tab-out or escape', async ({ page }) => {
|
test('cuesheet datagrid does not submit timer cells on tab-out or escape', async ({ page }) => {
|
||||||
await page.goto('/cuesheet');
|
await page.goto('/cuesheet');
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ test.describe('URL Preset', () => {
|
|||||||
.filter({ hasText: /^testingApply$/ })
|
.filter({ hasText: /^testingApply$/ })
|
||||||
.getByRole('button')
|
.getByRole('button')
|
||||||
.click();
|
.click();
|
||||||
await expect(page.getByText('Current')).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Applied' })).toBeVisible();
|
||||||
|
|
||||||
// 2. the URL contains the preset
|
// 2. the URL contains the preset
|
||||||
expect(page.url().includes('hideTimerSeconds=true')).toBeTruthy();
|
expect(page.url().includes('hideTimerSeconds=true')).toBeTruthy();
|
||||||
|
|||||||
@@ -13,28 +13,3 @@ test('View params configures timer view', async ({ page }) => {
|
|||||||
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
||||||
await expect(page).toHaveURL(/.*hideClock=true/);
|
await expect(page).toHaveURL(/.*hideClock=true/);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* The form gathers its values from the DOM, so a collapsed section has to stay mounted.
|
|
||||||
* Unmounting it would quietly drop everything the user set in it on the next apply.
|
|
||||||
*/
|
|
||||||
test('View params keeps the values of collapsed sections', async ({ page }) => {
|
|
||||||
// hideClock lives in the section we are about to collapse
|
|
||||||
await page.goto('/timer?hideClock=true');
|
|
||||||
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
|
||||||
|
|
||||||
await page.mouse.move(Math.random() * 100, Math.random() * 100);
|
|
||||||
await page.getByTestId('navigation__toggle-settings').click();
|
|
||||||
|
|
||||||
const section = page.getByRole('button', { name: /Element visibility/ });
|
|
||||||
await expect(section).toHaveAttribute('aria-expanded', 'true');
|
|
||||||
await section.focus();
|
|
||||||
await page.keyboard.press('Space');
|
|
||||||
await expect(section).toHaveAttribute('aria-expanded', 'false');
|
|
||||||
await expect(page.getByRole('dialog', { name: 'Ontime' })).toBeHidden();
|
|
||||||
|
|
||||||
await page.getByTestId('apply-view-params').click();
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/.*hideClock=true/);
|
|
||||||
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
|
||||||
});
|
|
||||||
|
|||||||
+5
-5
@@ -41,14 +41,14 @@
|
|||||||
"format:check": "oxfmt --check"
|
"format:check": "oxfmt --check"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.60.0",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"oxfmt": "^0.63.0",
|
"oxfmt": "^0.42.0",
|
||||||
"oxlint": "^1.78.0",
|
"oxlint": "^1.57.0",
|
||||||
"oxlint-tsgolint": "^7.0.2001",
|
"oxlint-tsgolint": "^0.17.4",
|
||||||
"rimraf": "catalog:",
|
"rimraf": "catalog:",
|
||||||
"turbo": "2.10.10",
|
"turbo": "2.8.20",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
|
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"nanoid": "^6.0.0"
|
"nanoid": "^6.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "catalog:",
|
|
||||||
"ontime-types": "workspace:*",
|
"ontime-types": "workspace:*",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
|
|||||||
Generated
+2118
-1151
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -4,10 +4,10 @@ packages:
|
|||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
'@types/node': 22.19.11
|
'@types/node': 22.19.11
|
||||||
rimraf: 6.1.3
|
rimraf: 6.0.1
|
||||||
ts-essentials: 10.2.1
|
ts-essentials: 10.1.1
|
||||||
typescript: 7.0.2
|
typescript: 7.0.2
|
||||||
vitest: 4.1.10
|
vitest: 4.0.17
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@parcel/watcher': true
|
'@parcel/watcher': true
|
||||||
|
|||||||
Reference in New Issue
Block a user