Compare commits

..

2 Commits

Author SHA1 Message Date
Claude e29a019376 feat(mcp): validate tool arguments with zod
The MCP endpoint was the only route accepting a body without validation. Its
JSON Schema tool declarations were advertisement only: the low-level SDK Server
validates the JSON-RPC envelope but never the arguments of a tool call, and
every handler cast them with `args as SomeType`.

Tools are now declared with `defineTool(name, config, handler)`, the argument
shape `registerTool` takes, and the JSON Schema advertised in tools/list is
generated from the same zod schema that parses incoming arguments, so the two
cannot drift. Handlers receive inferred arguments and pass them to the existing
services, whose argument types are derived from ontime-types — which means a
schema that drifts from the domain model now fails typecheck.

mcp.registry.ts is the only module aware of the SDK internals. Migrating to the
SDK v2 registerTool API means replacing its two functions with a registration
loop, leaving the tool declarations untouched.

Malformed calls are now protocol errors, matching what the SDK does on the
registerTool path and what v2 will do. Failures raised by the services are
still returned as tool errors so agents can read them and recover.

Fixes three bugs the missing validation allowed:

- Project filenames reached `join(projectsDir, name)` unsanitised, so
  `../canary.json` resolved outside the projects directory. They are now
  confined with the same sanitize-filename + ensureJsonExtension pair the HTTP
  routes use in db.validation.ts.
- ontime_update_project_info forwarded its whole argument object into
  setProjectData, which spreads it into the stored project; an undeclared
  `logo` key also caused the current logo file to be deleted. Only declared
  fields reach it now.
- Batch creation declared nested entries as a bare object. They are modelled
  two levels deep, which encodes that groups cannot be nested and keeps the
  generated schema free of $ref/$defs.

Unknown keys are rejected rather than stripped, so an agent gets an actionable
error instead of a write that silently does less than it reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZioYpY8NR8UM5tAQ7BkfN
2026-08-12 05:28:22 +00:00
Claude ae16491599 chore(server): add zod as a direct dependency
Zod 4.4.3 is already resolved in the lockfile as a transitive dependency of
@modelcontextprotocol/sdk, but pnpm's strict node_modules will not resolve it
from apps/server without an explicit entry. Pinned to the resolved version so
the server and the SDK share a single instance.

Version >= 4.2.0 is also what the SDK v2 upgrade will require, since v2 takes
Standard Schema objects for tool schemas and drops zod v3 support.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZioYpY8NR8UM5tAQ7BkfN
2026-08-12 05:28:08 +00:00
144 changed files with 4575 additions and 5587 deletions
-46
View File
@@ -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.
-22
View File
@@ -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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.13.0",
"version": "4.12.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+8 -9
View File
@@ -1,10 +1,10 @@
{
"name": "ontime-ui",
"version": "4.13.0",
"version": "4.12.0",
"private": true,
"type": "module",
"dependencies": {
"@base-ui/react": "1.7.0",
"@base-ui/react": "1.6.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -13,12 +13,12 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^9.5.1",
"@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^9.1.2",
"@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1",
"axios": "^1.18.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0",
"react-icons": "5.7.0",
"react-icons": "5.6.0",
"react-router": "^8.0.1",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.14"
@@ -60,8 +60,7 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "5.4.0",
"@types/node": "catalog:",
"@sentry/vite-plugin": "5.1.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
@@ -73,8 +72,8 @@
"ontime-utils": "workspace:*",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "8.2.1",
"vite-plugin-compression2": "2.5.3",
"vite": "8.0.1",
"vite-plugin-compression2": "2.5.1",
"vite-plugin-svgr": "4.5.0",
"vitest": "catalog:"
}
+1 -4
View File
@@ -3,7 +3,6 @@ import { ComponentType, Suspense, lazy, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { EditableRundownScopeProvider } from './common/context/EditableRundownScopeProvider';
import { PresetContext } from './common/context/PresetContext';
import useUrlPresets from './common/hooks-query/useUrlPresets';
import { useClientPath } from './common/hooks/useClientPath';
@@ -113,9 +112,7 @@ export default function AppRouter() {
path='rundown'
element={
<EditorFeatureWrapper>
<EditableRundownScopeProvider rundownId={null}>
<RundownPanel />
</EditableRundownScopeProvider>
<RundownPanel />
</EditorFeatureWrapper>
}
/>
Binary file not shown.
Binary file not shown.
Binary file not shown.
-8
View File
@@ -22,14 +22,6 @@ export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
/**
* Cache key holding the data for a rundown.
* Before the loaded rundown is known there is no id to key by,
* and the data lives under the bootstrap alias.
*/
export const getRundownCacheKey = (rundownId: string) =>
rundownId ? getRundownQueryKey(rundownId) : CURRENT_RUNDOWN_QUERY_KEY;
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
@@ -1,18 +0,0 @@
import { ComponentProps } from 'react';
import Button from './Button';
type ToggleButtonProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
/** whether the option this button controls is currently on */
pressed: boolean;
};
/**
* A button which carries an on / off state.
*
* Keeps the pressed styling and the accessible state together, so that a toggle
* cannot end up looking active without also announcing that it is.
*/
export default function ToggleButton({ pressed, ...buttonProps }: ToggleButtonProps) {
return <Button variant={pressed ? 'primary' : 'subtle'} aria-pressed={pressed} {...buttonProps} />;
}
@@ -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 {
position: fixed;
inset: 0;
@@ -19,15 +23,14 @@
width: 22rem;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
padding-block: 1rem;
background-color: $gray-1250;
color: $ui-white;
border-right: 1px solid $gray-1100;
box-shadow: $box-shadow-right;
&[data-open] {
transform: translateX(0%);
@@ -47,51 +50,22 @@
}
.header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 4rem;
padding: 0.75rem 1rem 0.75rem 1.25rem;
border-bottom: 1px solid $white-10;
}
height: 3.5rem;
padding-inline: 1.5rem;
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 1.25rem;
line-height: 1.2;
}
.clientName {
font-size: $aux-text-size;
color: $gray-600;
@include ellipsis-text;
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-block: 0.5rem 1rem;
}
.group {
display: flex;
flex-direction: column;
&:not(:first-child) {
margin-top: 0.75rem;
}
}
.groupLabel {
padding: 0.5rem 1.25rem 0.25rem;
color: $gray-700;
.note {
margin-left: auto;
color: $white-20;
}
@@ -1,7 +1,7 @@
import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
import { memo, PropsWithChildren } from 'react';
import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router';
@@ -14,11 +14,9 @@ import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import Eyebrow from '../eyebrow/Eyebrow';
import ClientLink from './client-link/ClientLink';
import EditorNavigation from './editor-navigation/EditorNavigation';
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
import OtherAddresses from './other-addresses/OtherAddresses';
import style from './NavigationMenu.module.scss';
@@ -35,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreenDocument();
const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation();
@@ -54,93 +52,75 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
<Dialog.Popup className={style.drawer}>
<div className={style.header}>
<div className={style.headerText}>
<Dialog.Title className={style.title}>Ontime</Dialog.Title>
{name && <span className={style.clientName}>{name}</span>}
</div>
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
<Dialog.Title>Ontime</Dialog.Title>
<IconButton variant='subtle-white' size='large' onClick={onClose}>
<IoClose />
</IconButton>
</div>
<div className={style.body}>
<MenuGroup label='This screen'>
{supportsFullscreen && (
<NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} />
)}
<NavigationMenuToggle
checked={mirror}
icon={<IoSwapVertical />}
label='Flip Screen'
onToggle={() => toggleMirror()}
/>
{canUseWakeLock && (
<NavigationMenuToggle
checked={keepAwake}
icon={<LuCoffee />}
label='Keep Awake'
onToggle={toggleKeepAwake}
/>
)}
<NavigationMenuItem onClick={handlers.open}>
<IoPencilOutline />
Rename Client
{supportsFullscreen && (
<NavigationMenuItem active={fullscreen} onClick={toggle}>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</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>
<MenuGroup label='Ontime app'>
<EditorNavigation />
<hr className={style.separator} />
<EditorNavigation />
<ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Cuesheet
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline />
Operator
</ClientLink>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Cuesheet
{route.label}
</ClientLink>
<ClientLink
to='op'
current={location.pathname === '/op'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Operator
</ClientLink>
</MenuGroup>
<MenuGroup label='Views'>
{navigatorConstants.map((route) => (
<ClientLink
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label}
</ClientLink>
))}
</MenuGroup>
))}
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div>
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
{isLocalhost && (
<div>
<OtherAddresses currentLocation={location.pathname} />
</div>
)}
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.group}>
<div className={style.groupLabel}>
<Eyebrow>{label}</Eyebrow>
</div>
{children}
</div>
);
}
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation();
const { data: urlPresets } = useUrlPresets();
@@ -149,7 +129,8 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
if (navPresets.length === 0) return null;
return (
<MenuGroup label='Presets'>
<>
<hr className={style.separator} />
{navPresets.map((preset) => (
<ClientLink
key={preset.alias}
@@ -160,6 +141,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
{preset.alias}
</ClientLink>
))}
</MenuGroup>
</>
);
}
@@ -1,5 +1,5 @@
import { useHotkeys } from '@mantine/hooks';
import { memo, useCallback, useState } from 'react';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react';
import { useSearchParams } from 'react-router';
import { hasCustomParams } from '../../stores/savedViewParams';
@@ -15,37 +15,21 @@ interface ViewNavigationMenuProps {
suppressSettings?: boolean;
}
function isInteractiveKeyboardAction(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target.closest(
'button, a, input, textarea, select, [role="button"], [role="checkbox"], [role="switch"], [contenteditable]:not([contenteditable="false"])',
) !== null
);
}
export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
const hasSavedChanges = hasCustomParams(searchParams);
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
useHotkeys([
[
'Space',
(event) => {
if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
event.preventDefault();
toggleMenu();
() => {
if (isNavigationLocked) return;
menuHandler.toggle();
},
{ preventDefault: false },
{ preventDefault: true },
],
[
'mod + ,',
@@ -64,11 +48,11 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
return (
<>
<FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : toggleMenu}
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges}
/>
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />}
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
</>
);
}
@@ -2,68 +2,35 @@
display: flex;
align-items: center;
padding: 0.625rem 1.25rem;
gap: 0.625rem;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
width: 100%;
border-left: 3px solid transparent;
color: $gray-200;
font-size: $text-body-size;
border-left: 4px solid transparent;
color: $action-text-color;
white-space: nowrap;
text-align: left;
cursor: pointer;
transition-property: color, background-color;
transition-property: color;
transition-duration: $transition-time-action;
// keep icons on a fixed box so labels align regardless of the glyph
> svg {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
color: $gray-500;
transition: color $transition-time-action;
}
&:hover {
background-color: $white-3;
color: $ui-white;
> svg {
color: $gray-300;
}
color: $ontime-color;
background-color: $gray-1350;
}
&:active {
background-color: $white-7;
background-color: $border-color-ondark;
}
&:focus-visible {
&:focus {
outline: 2px solid $blue-500;
outline-offset: -2px;
background-color: $white-3;
}
// toggle rows are labels, the focusable control sits inside them
&:focus-within {
background-color: $white-3;
background-color: $gray-1350;
}
&.current {
background-color: $bg-active;
border-left-color: $blue-400;
color: $ui-white;
font-weight: 600;
> svg {
color: $blue-300;
}
background-color: $gray-1300;
border-left: 4px solid $action-text-color;
}
}
.label {
flex: 1;
min-width: 0;
@include ellipsis-text;
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react';
import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent';
import { isKeyEnter } from '../../../utils/keyEvent';
import { cx } from '../../../utils/styleUtils';
import style from './NavigationMenuItem.module.scss';
@@ -11,7 +11,6 @@ interface NavigationMenuItemProps {
onClick: () => void;
}
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
export default function NavigationMenuItem({
active,
className,
@@ -25,9 +24,7 @@ export default function NavigationMenuItem({
role='button'
onClick={onClick}
onKeyDown={(event) => {
if (isKeyEnter(event) || isKeySpace(event)) {
event.preventDefault();
event.stopPropagation();
if (isKeyEnter(event)) {
onClick();
}
}}
@@ -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>
);
}
@@ -1,52 +1,22 @@
.footer {
flex: 0 0 auto;
padding: 0.75rem 0;
border-top: 1px solid $white-10;
}
.header {
padding: 0 1.25rem 0.25rem;
font-size: calc(1rem - 2px);
margin-left: 1rem;
color: $gray-700;
}
.interfaces {
padding: 0.25rem 1.25rem 0;
padding: 0.5rem 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.interface {
min-width: 0;
max-width: 100%;
> div {
max-width: 100%;
> button:first-child {
min-width: 0;
max-width: calc(100% - 1.5rem);
justify-content: flex-start;
padding-inline: 0.5rem;
}
}
gap: 0.5rem;
}
.interfaceCopy {
display: flex;
align-items: center;
min-width: 0;
width: 100%;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.goIcon {
flex: 0 0 auto;
@include rotate-fourty-five;
margin-left: 0.25rem;
margin-bottom: 0.25rem;
@@ -3,8 +3,6 @@ import { IoArrowUp } from 'react-icons/io5';
import useInfo from '../../../hooks-query/useInfo';
import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
import CopyTag from '../../copy-tag/CopyTag';
import Eyebrow from '../../eyebrow/Eyebrow';
import { getExternalInterfaces } from './otherAddresses.utils';
import style from './OtherAddresses.module.scss';
@@ -14,33 +12,32 @@ interface OtherAddressesProps {
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
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 (
<div className={style.footer}>
<div className={style.header}>
<Eyebrow>Also available at</Eyebrow>
</div>
<>
<div className={style.header}>Accessible on external networks</div>
<div className={style.interfaces}>
{externalInterfaces.map((nif) => {
{data?.networkInterfaces?.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation);
return (
<div key={nif.name} className={style.interface}>
<CopyTag copyValue={address} onClick={() => openLink(address)} size='small'>
<span className={style.interfaceCopy}>
<span className={style.address}>{nif.address}</span>
<IoArrowUp className={style.goIcon} />
</span>
</CopyTag>
</div>
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
<span className={style.interfaceCopy}>
{nif.address} <IoArrowUp className={style.goIcon} />
</span>
</CopyTag>
);
})}
</div>
</div>
</>
);
}
@@ -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' },
]);
});
});
@@ -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;
align-items: center;
gap: 0.5rem;
> span {
font-variant-numeric: tabular-nums;
text-align: right;
text-transform: uppercase;
}
}
// attempt to match with ontimeTextInputs
@@ -1,77 +1,37 @@
.multiOptionList {
overflow-y: auto;
max-height: 12rem;
background: $gray-1200;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
.inline {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
// styles from subtle button
.toggleSelect {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 2.5rem;
padding: 0.5rem 0.75rem;
background: transparent;
border: 0;
color: $gray-300;
gap: 0.25rem;
padding-inline: 0.5rem;
height: 2.5rem;
background: $gray-1050;
color: $ui-white;
line-height: 1em;
cursor: pointer;
transition:
background-color $transition-time-action,
border-color $transition-time-action,
color $transition-time-action;
border-radius: $component-border-radius-md;
&:hover {
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
color: $blue-500;
}
&:active {
&:active:not(:disabled) {
background: $gray-1100;
}
&: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;
}
border-color: $gray-1250;
}
&::after {
content: '';
flex: 0 0 auto;
margin-left: auto;
width: 0.625rem;
height: 0.625rem;
margin-left: 0.25rem;
width: 0.75em;
height: 0.75em;
background: var(--user-bg, $gray-900);
border-radius: 50%;
}
@@ -2,7 +2,6 @@ import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../views/common/viewUtils';
import { cx } from '../../utils/styleUtils';
import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select';
@@ -108,20 +107,18 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
return (
<>
<input name={id} hidden readOnly value={paramState.join(',')} />
<div className={style.multiOptionList}>
<div className={style.inline}>
{values.map((option) => {
const selected = paramState.includes(option.value);
return (
<label
key={option.value}
className={cx([style.toggleSelect, selected && style.selected])}
className={style.toggleSelect}
style={{
'--user-bg': option.colour,
}}
>
<Checkbox
checked={selected}
checked={paramState.includes(option.value)}
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
/>
{option.label}
@@ -1,3 +1,13 @@
.drawerFooter {
display: flex;
justify-content: end;
gap: $section-spacing;
button {
padding: 0 2em;
}
}
.backdrop {
position: fixed;
inset: 0;
@@ -24,11 +34,11 @@
display: flex;
flex-direction: column;
padding-block: 1rem 1.5rem;
background-color: $ui-black;
background-color: $gray-1250;
color: $ui-white;
border-left: 1px solid $gray-1100;
box-shadow: $box-shadow-l1;
&[data-open] {
transform: translateX(0%);
@@ -48,54 +58,36 @@
}
.header {
flex: 0 0 auto;
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 4rem;
padding: 0.75rem 1rem;
}
height: 3.5rem;
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 1.25rem;
line-height: 1.2;
}
.target {
font-size: $aux-text-size;
color: $gray-600;
}
.body {
flex: 1;
padding: 1rem;
padding-inline: 1rem;
padding-bottom: 10vh;
overflow-y: auto;
}
.footer {
flex: 0 0 auto;
display: flex;
padding: 0.75rem 1rem;
padding-inline: 1rem;
gap: 1rem;
align-items: center;
justify-content: flex-end;
background-color: $ui-black;
border-top: 1px solid $white-10;
}
.sectionList {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 2rem;
padding-right: 0.5rem;
}
.info {
@@ -4,7 +4,6 @@ import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { viewLabels } from '../../../viewerConfig';
import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams';
@@ -72,17 +71,8 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
<Dialog.Backdrop className={style.backdrop} />
<Dialog.Popup className={style.drawer}>
<div className={style.header}>
<div className={style.headerText}>
<Dialog.Title className={style.title}>Customise</Dialog.Title>
<span className={style.target}>{viewLabels[target]}</span>
</div>
<IconButton
variant='subtle-white'
size='large'
aria-label='Close'
data-testid='close-view-params'
onClick={handleClose}
>
<Dialog.Title>Customise</Dialog.Title>
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
<IoClose />
</IconButton>
</div>
@@ -1,21 +1,10 @@
.presetSection {
background-color: $gray-1350;
border: 1px solid $white-10;
border-radius: $component-border-radius-md;
margin-bottom: 0.75rem;
}
.header {
padding: 0.5rem 1rem;
min-height: 2.25rem;
display: flex;
align-items: center;
color: $gray-300;
}
.presetList {
border-top: 1px solid $white-7;
flex-direction: column;
gap: 0.25rem;
padding: 1rem 0.5rem;
margin-bottom: 1rem;
max-height: 10rem;
overflow-y: auto;
scrollbar-gutter: stable;
@@ -25,39 +14,12 @@
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 2.5rem;
padding: 0.375rem 1rem;
font-size: $inner-section-text-size;
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
&.active {
background-color: $bg-active;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
font-weight: 600;
color: $blue-500;
}
}
.presetName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presetActions {
margin-left: auto;
}
.current {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
color: $blue-400;
font-size: $aux-text-size;
font-weight: 600;
}
@@ -4,7 +4,6 @@ import { useSearchParams } from 'react-router';
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button';
import Eyebrow from '../eyebrow/Eyebrow';
import style from './ViewParamsPresets.module.scss';
@@ -27,31 +26,22 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
return (
<div className={style.presetSection}>
<div className={style.header}>
<Eyebrow>Saved presets</Eyebrow>
</div>
<div className={style.presetList}>
{viewPresets.map((preset) => {
const active = searchParams.get('alias') === preset.alias;
return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
<span className={style.presetName}>{preset.alias}</span>
{active ? (
<span className={style.current}>Current</span>
) : (
<Button
variant='ghosted'
size='small'
onClick={() => handleRecall(preset)}
className={style.presetActions}
>
Apply
</Button>
)}
</div>
);
})}
</div>
{viewPresets.map((preset) => {
const active = searchParams.get('alias') === preset.alias;
return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
<div>{preset.alias}</div>
<Button
variant={active ? 'ghosted' : 'subtle-white'}
onClick={() => handleRecall(preset)}
disabled={active}
className={style.presetActions}
>
{active ? 'Applied' : 'Apply'}
</Button>
</div>
);
})}
</div>
);
}
@@ -1,118 +1,48 @@
.section {
overflow: hidden;
color: $ui-white;
font-size: 1rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 1rem;
background: transparent;
border: 0;
font: inherit;
text-align: left;
color: $gray-300;
height: 2rem;
&.collapsible {
cursor: pointer;
transition-property: color, background-color;
transition-duration: $transition-time-action;
&:hover {
background-color: $white-3;
color: $ui-white;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
}
}
.sectionTitle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/** marks a section which holds values coming from the URL */
.customised {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background-color: $active-indicator;
flex-shrink: 0;
}
.chevron {
flex-shrink: 0;
rotate: 180deg;
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&.closed {
rotate: 0deg;
}
}
.options {
padding-inline: 1rem;
border-top: 1px solid $white-7;
}
.label {
display: grid;
grid-template-columns: minmax(0, 1fr);
row-gap: 0.25rem;
padding: 0.75rem 0;
margin-top: $section-spacing;
font-size: $inner-section-text-size;
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
}
/**
* Compact controls sit beside their label instead of below it.
* Placement is done with grid areas so that the markup stays flat.
*/
.inline {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
column-gap: 1rem;
> .title {
grid-area: 1 / 1;
}
> .description {
grid-area: 2 / 1;
}
> *:last-child {
grid-area: 1 / 2 / span 2;
}
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title {
color: $ui-white;
font-size: $inner-section-text-size;
display: block;
width: 100%;
}
.description {
color: $gray-400;
font-size: $aux-text-size;
line-height: 1.4;
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
}
.closed {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 0deg;
}
.open {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 180deg;
}
.hidden {
@@ -1,9 +1,7 @@
import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { cx } from '../../utils/styleUtils';
import Eyebrow from '../eyebrow/Eyebrow';
import { OptionTitle } from './constants';
import ParamInput from './ParamInput';
import { type ParamField } from './viewParams.types';
@@ -16,74 +14,50 @@ interface ViewParamsSectionProps {
options: ParamField[];
}
/** Fields which are compact enough to share a row with their label */
function isInlineField(option: ParamField): boolean {
return option.type === 'boolean' || option.type === 'colour';
}
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const [searchParams] = useSearchParams();
// hidden options are inputs without a UI, there is nothing to frame them with
if (title === OptionTitle.Hidden) {
return <HiddenContents options={options} />;
}
const isCollapsed = Boolean(collapsible && collapsed);
const handleCollapse = () => {
if (collapsible) {
setCollapsed((prev) => !prev);
}
};
return (
<section className={style.section}>
<SectionHeader
title={title}
isCustomised={options.some((option) => searchParams.has(option.id))}
collapsed={isCollapsed}
onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined}
/>
{/* collapsed options stay mounted: the form reads its values from the DOM */}
<div className={cx([style.options, isCollapsed && style.hidden])}>
{options.map((option) => (
<label key={option.title} className={cx([style.label, isInlineField(option) && style.inline])}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
))}
</div>
{title === OptionTitle.Hidden ? (
<HiddenContents options={options} />
) : (
<>
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
{title}
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
</div>
<SectionContents options={options} collapsed={collapsed} />
</>
)}
</section>
);
}
interface SectionHeaderProps {
title: string;
isCustomised: boolean;
interface SectionContentsProps {
options: ParamField[];
collapsed: boolean;
/** a header without a toggle belongs to a section which cannot collapse */
onToggle?: () => void;
}
function SectionHeader({ title, isCustomised, collapsed, onToggle }: SectionHeaderProps) {
const label = (
<Eyebrow className={style.sectionTitle}>
{title}
{isCustomised && <span className={style.customised} title='Contains custom values' />}
</Eyebrow>
);
if (!onToggle) {
return <div className={style.sectionHeader}>{label}</div>;
}
function SectionContents({ options, collapsed }: SectionContentsProps) {
return (
<button
type='button'
className={cx([style.sectionHeader, style.collapsible])}
aria-expanded={!collapsed}
onClick={onToggle}
>
{label}
<IoChevronDown className={cx([style.chevron, collapsed && style.closed])} />
</button>
<>
{options.map((option) => {
return (
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
);
})}
</>
);
}
@@ -1,24 +0,0 @@
import { PropsWithChildren } from 'react';
import { useScopedEntryActions } from '../hooks/useEntryAction';
import { EntryActionsProvider } from './EntryActionsContext';
import { RundownScopeProvider, useRundownScope, type RundownScopeProviderProps } from './RundownScopeContext';
/**
* Rundown scope for subtrees which mutate entries.
* Actions are bound to the same rundown as the data, so the two cannot disagree.
*/
export function EditableRundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
return (
<RundownScopeProvider rundownId={rundownId}>
<ScopedEntryActions>{children}</ScopedEntryActions>
</RundownScopeProvider>
);
}
function ScopedEntryActions({ children }: PropsWithChildren) {
const { rundownId } = useRundownScope();
const actions = useScopedEntryActions(rundownId);
return <EntryActionsProvider actions={actions}>{children}</EntryActionsProvider>;
}
@@ -1,77 +0,0 @@
import { MaybeString, Rundown } from 'ontime-types';
import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { getRundownCacheKey } from '../api/constants';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
import { ontimeQueryClient } from '../queryClient';
import { createEventSelectionStore, type EventSelectionStoreApi } from '../stores/eventSelectionStore';
export type RundownScopeValue = {
/** the rundown this subtree operates on */
rundownId: string;
/** whether this scope targets the rundown the runtime is playing */
isLoaded: boolean;
/** selection and cursor, scoped to this rundown */
selectionStore: EventSelectionStoreApi;
};
const RundownScopeContext = createContext<RundownScopeValue | null>(null);
export interface RundownScopeProviderProps extends PropsWithChildren {
/** rundown to operate on, null follows the loaded rundown */
rundownId: MaybeString;
}
/**
* Declares which rundown a subtree reads from.
*
* Data hooks resolve their rundown from here, so components never need to know
* which rundown they operate on. Nest a provider to point part of the tree at a
* different rundown; the app mounts one at the root that follows the loaded rundown.
*/
export function RundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) {
const {
data: { loaded },
} = useProjectRundowns();
const targetId = rundownId ?? loaded;
// the store reads the rundown lazily, the ref keeps it pointing at the current target
const targetIdRef = useRef(targetId);
const selectionStoreRef = useRef<EventSelectionStoreApi | null>(null);
if (selectionStoreRef.current === null) {
selectionStoreRef.current = createEventSelectionStore(() =>
ontimeQueryClient.getQueryData<Rundown>(getRundownCacheKey(targetIdRef.current)),
);
}
const selectionStore = selectionStoreRef.current;
// a selection refers to entries of a single rundown, it cannot survive a change of target
useEffect(() => {
targetIdRef.current = targetId;
selectionStore.getState().clearSelectedEvents();
}, [selectionStore, targetId]);
const value = useMemo(
(): RundownScopeValue => ({
rundownId: targetId,
// an unresolved target is not the loaded rundown, it is not yet any rundown
isLoaded: Boolean(loaded) && targetId === loaded,
selectionStore,
}),
[targetId, loaded, selectionStore],
);
return <RundownScopeContext.Provider value={value}>{children}</RundownScopeContext.Provider>;
}
export function useRundownScope(): RundownScopeValue {
const context = useContext(RundownScopeContext);
if (!context) {
throw new Error('useRundownScope must be used within a RundownScopeProvider');
}
return context;
}
@@ -1,73 +0,0 @@
import { EntryId, OntimeEntry } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { flattenRundown, useRundownById } from './useRundownById';
/**
* Rundown data for surfaces which only ever work against the rundown being played:
* the viewers, the operator, app settings and the runtime overview.
*
* These take no part in a rundown scope, they resolve the loaded rundown directly.
* Anything which can be pointed at a background rundown reads from its scope instead.
*/
export function useLoadedRundown() {
const {
data: { loaded },
} = useProjectRundowns();
return useRundownById(loaded);
}
export function useLoadedRundownWithMetadata() {
const { data, status } = useLoadedRundown();
const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
}
export function useLoadedFlatRundown() {
const { data, status } = useLoadedRundown();
const flatRundown = useMemo(() => flattenRundown(data), [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useLoadedFlatRundownWithMetadata() {
const { data, status } = useLoadedRundown();
const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*/
export function useLoadedPartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useLoadedFlatRundownWithMetadata();
const filteredData = useMemo(() => data.filter(cb), [data, cb]);
return { data: filteredData, status };
}
/**
* Hook to get a specific entry by ID from the loaded rundown.
* Runtime ids (the playing event, its group, the next flag) only exist here.
*/
export function useLoadedEntry(entryId: EntryId | null): OntimeEntry | null {
const { data: rundown } = useLoadedRundown();
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
export function useLoadedRundownAuxData() {
const { data, status } = useLoadedRundown();
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
return { data: filteredData, status };
}
@@ -1,32 +1,61 @@
import { EntryId, OntimeEntry } from 'ontime-types';
import { useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { useRundownScope } from '../context/RundownScopeContext';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { flattenRundown, useRundownById } from './useRundownById';
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/**
* Normalised rundown data for the rundown of the enclosing scope
* Normalised rundown data for the currently loaded rundown.
*
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
* independent of the project rundown list. Once the loaded id is known, the
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
*/
export default function useRundown() {
const { rundownId } = useRundownScope();
return useRundownById(rundownId);
}
const queryClient = useQueryClient();
const {
data: { loaded: loadedRundownId },
} = useProjectRundowns();
/**
* Runtime state only describes the loaded rundown,
* a scope pointed elsewhere must not show a playing event
*/
export function useScopedSelectedEventId(): EntryId | null {
const { isLoaded } = useRundownScope();
const selectedEventId = useSelectedEventId();
return isLoaded ? selectedEventId : null;
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
if (!data || loadedRundownId) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, loadedRundownId, queryClient]);
// Once we have the ID, drop the temporary current cache
useEffect(() => {
if (!loadedRundownId) return;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [loadedRundownId, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
export function useRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useScopedSelectedEventId();
const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata };
}
@@ -37,19 +66,41 @@ export function useRundownWithMetadata() {
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const flatRundown = useMemo(() => flattenRundown(data), [data]);
const flatRundown = useMemo(() => {
if (data.revision === -1) {
return [];
}
return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
}, [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
const selectedEventId = useScopedSelectedEventId();
const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*
* Callers MUST memoize the callback with useCallback to prevent
* re-filtering on every render.
*
*/
export function usePartialRundown(cb: (event: ExtendedEntry<OntimeEntry>) => boolean) {
const { data, status } = useFlatRundownWithMetadata();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
return { data: filteredData, status };
}
/**
* Hook to get a specific entry by ID from the rundown
*/
@@ -59,3 +110,30 @@ export function useEntry(entryId: EntryId | null): OntimeEntry | null {
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
export function useRundownAuxData() {
const { data, status } = useRundown();
const filteredData = useMemo(() => {
const { title, id } = data;
return { title, id };
}, [data]);
return { data: filteredData, status };
}
/**
* Provides access to a specific rundown by ID.
* When rundownId is null/undefined the query is disabled and returns the placeholder.
*/
export function useRundownById(rundownId: string | null | undefined) {
const enabled = Boolean(rundownId);
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownQueryKey(rundownId ?? ''),
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
enabled,
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
@@ -1,66 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { OntimeEntry, Rundown } from 'ontime-types';
import { useEffect, useRef } from 'react';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownCacheKey, getRundownQueryKey } from '../api/constants';
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/**
* Provides access to a specific rundown by ID.
*
* Without an ID we do not yet know which rundown is loaded, so we bootstrap via
* the `/current` alias to keep the first paint to a single round-trip, then seed
* the id-keyed cache that every other reader shares.
*/
export function useRundownById(rundownId: string | null | undefined) {
const queryClient = useQueryClient();
const id = rundownId ?? '';
const isBootstrap = id === '';
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: getRundownCacheKey(id),
queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })),
placeholderData: (previousData, _previousQuery) => previousData,
refetchInterval: queryRefetchIntervalSlow,
});
// Seed the id-keyed cache when fetching via the bootstrap alias
useEffect(() => {
if (!data || !isBootstrap) return;
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, isBootstrap, queryClient]);
// Once we have the ID, drop the temporary current cache.
// Only the reader which bootstrapped may do so, others are still relying on it.
const didBootstrap = useRef(isBootstrap);
useEffect(() => {
if (isBootstrap || !didBootstrap.current) return;
didBootstrap.current = false;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
}, [isBootstrap, queryClient]);
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
/**
* Builds a flat rundown from the order and entries fields
*/
export function flattenRundown(rundown: Rundown): OntimeEntry[] {
if (rundown.revision === -1) {
return [];
}
return rundown.flatOrder
.map((id) => rundown.entries[id])
.filter((entry): entry is OntimeEntry => entry !== undefined);
}
@@ -0,0 +1,54 @@
import { EntryId, Rundown } from 'ontime-types';
import { useMemo } from 'react';
import { useSelectedEventId } from '../hooks/useSocket';
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
import { useProjectRundowns } from './useProjectRundowns';
import { useRundownById } from './useRundown';
export type RundownSource = {
rundownId: string | null;
rundown: Rundown;
flatRundown: ExtendedEntry[];
status: string;
selectedEventId: EntryId | null;
};
/**
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
*/
export function useScopedRundown(rundownId: string | null): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
return useRundownSource(rundownId, projectRundowns.loaded || null);
}
/**
* Loaded-rundown source for views that must follow the active runtime rundown.
*/
export function useLoadedRundownSource(): RundownSource {
const { data: projectRundowns } = useProjectRundowns();
const loadedRundownId = projectRundowns.loaded || null;
return useRundownSource(loadedRundownId, loadedRundownId);
}
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
const runtimeSelectedEventId = useSelectedEventId();
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
const { data: rundown, status } = useRundownById(rundownId);
const flatRundown = useMemo(
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
[effectiveSelectedEventId, rundown],
);
return useMemo(
() => ({
rundownId,
rundown,
flatRundown,
status,
selectedEventId: effectiveSelectedEventId,
}),
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
);
}
+12 -8
View File
@@ -8,6 +8,7 @@ import {
OntimeGroup,
OntimeMilestone,
PatchWithId,
ProjectRundownsList,
Rundown,
SupportedEntry,
TimeField,
@@ -33,7 +34,7 @@ import {
import { useCallback, useMemo } from 'react';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
import { getRundownCacheKey } from '../api/constants';
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../api/constants';
import {
ReorderEntry,
deleteEntries,
@@ -51,7 +52,6 @@ import {
requestFitGroupTarget,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useRundownScope } from '../context/RundownScopeContext';
import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
@@ -70,16 +70,16 @@ type ClientInsertOptions = {
};
/**
* Gather utilities for actions on entries in the rundown of the enclosing scope.
* Gather utilities for actions on entries in the loaded rundown.
*/
export const useEntryActions = () => useEntryActionsForRundown(useRundownScope().rundownId);
export const useEntryActions = () => useEntryActionsForRundown(undefined);
/**
* Gather utilities for actions on entries in an explicitly selected rundown.
*/
export const useScopedEntryActions = (rundownId: MaybeString) => useEntryActionsForRundown(rundownId ?? '');
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
function useEntryActionsForRundown(scopedRundownId: string) {
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
const queryClient = useQueryClient();
const {
linkPrevious,
@@ -93,8 +93,12 @@ function useEntryActionsForRundown(scopedRundownId: string) {
} = useEditorSettings();
const resolveCurrentRundownQueryKey = useCallback(() => {
return getRundownCacheKey(scopedRundownId);
}, [scopedRundownId]);
if (scopedRundownId !== undefined) {
return getRundownQueryKey(scopedRundownId);
}
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
}, [queryClient, scopedRundownId]);
/**
* Returns the currently loaded rundown
@@ -1,64 +0,0 @@
import { Rundown, SupportedEntry } from 'ontime-types';
import { createEventSelectionStore } from '../eventSelectionStore';
function makeRundown(id: string, eventIds: string[]): Rundown {
return {
id,
title: id,
order: eventIds,
flatOrder: eventIds,
entries: Object.fromEntries(eventIds.map((entryId) => [entryId, { id: entryId, type: SupportedEntry.Event }])),
revision: 1,
} as unknown as Rundown;
}
describe('createEventSelectionStore', () => {
it('keeps selections of separate instances independent', () => {
const first = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
const second = createEventSelectionStore(() => makeRundown('rundown-b', ['b1', 'b2']));
first.getState().setSingleEntrySelection({ id: 'a1' });
second.getState().setSingleEntrySelection({ id: 'b2' });
expect(first.getState().cursor).toBe('a1');
expect(second.getState().cursor).toBe('b2');
expect(first.getState().selectedEvents).toEqual(new Set(['a1']));
expect(second.getState().selectedEvents).toEqual(new Set(['b2']));
});
it('resolves a shift range against the injected rundown', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2', 'a3']));
store.getState().setSelectedEvents({ id: 'a3', index: 2, selectMode: 'shift' });
// without an anchor the range runs from the top up to the clicked index
expect(store.getState().selectedEvents).toEqual(new Set(['a1', 'a2']));
expect(store.getState().anchoredIndex).toBe(2);
});
it('replaces the selection set so subscribers on the set re-render', () => {
const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'click' });
const afterClick = store.getState().selectedEvents;
store.getState().setSelectedEvents({ id: 'a2', index: 1, selectMode: 'ctrl' });
const afterAdd = store.getState().selectedEvents;
expect(afterAdd).not.toBe(afterClick);
expect(afterAdd).toEqual(new Set(['a1', 'a2']));
store.getState().unselect('a1');
const afterUnselect = store.getState().selectedEvents;
expect(afterUnselect).not.toBe(afterAdd);
expect(afterUnselect).toEqual(new Set(['a2']));
});
it('does not select when the rundown is unavailable', () => {
const store = createEventSelectionStore(() => undefined);
store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'shift' });
expect(store.getState().selectedEvents).toEqual(new Set());
});
});
@@ -1,21 +1,14 @@
import { MaybeString } from 'ontime-types';
import { create } from 'zustand';
type EntryCopyStore = {
entryCopyId: MaybeString;
/** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */
entryCopyRundownId: MaybeString;
entryCopyId: string | null;
entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: MaybeString, rundownId: MaybeString, mode?: 'copy' | 'cut') => void;
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
};
/**
* The clipboard is shared across rundowns so entries can be moved between them
*/
export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null,
entryCopyRundownId: null,
entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: MaybeString, entryCopyRundownId: MaybeString, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }),
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
}));
@@ -1,158 +0,0 @@
import { EntryId, MaybeNumber, Rundown, isOntimeEvent } from 'ontime-types';
import { MouseEvent } from 'react';
import { StoreApi } from 'zustand';
import { createStore } from 'zustand/vanilla';
import { isMacOS } from '../utils/deviceUtils';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
export interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
export type EventSelectionStoreApi = StoreApi<EventSelectionStore>;
/**
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
*
* One store instance exists per rundown scope, so panels showing different
* rundowns keep independent selections. The rundown the selection refers to is
* injected as `getRundown` rather than resolved from the loaded rundown.
*/
export function createEventSelectionStore(getRundown: () => Rundown | undefined): EventSelectionStoreApi {
return createStore<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in single mode, we replace the selection and change the mode
if (entryMode === 'single') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = getRundown();
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: new Set(selectedEvents).add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
const withoutId = new Set(selectedEvents);
withoutId.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && withoutId.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents: withoutId,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = getRundown();
if (!rundownData) return;
// get list of rundown with only ontime events
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
eventIds.push(event.id);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({
selectedEvents: new Set(firstSelected ? [firstSelected] : []),
anchoredIndex: null,
entryMode: null,
});
},
unselect: (id: string) => {
const { entryMode, selectedEvents } = get();
const remaining = new Set(selectedEvents);
remaining.delete(id);
set({
selectedEvents: remaining,
entryMode: remaining.size === 0 ? null : entryMode,
});
},
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
handler(id);
}
},
}));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
}
-4
View File
@@ -4,10 +4,6 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Enter';
}
export function isKeySpace<T>(event: KeyboardEvent<T>): boolean {
return event.key === ' ' || event.code === 'Space';
}
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape';
}
+3 -3
View File
@@ -242,12 +242,12 @@ export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: s
return;
}
// skip if we dont recognise the ID the revision is lower
const queryKey = getRundownQueryKey(rundownId);
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
// we already have this change, or something newer
// messages can arrive after a refetch has already brought in a later revision
if (revision !== null && cachedRundown !== undefined && revision <= cachedRundown.revision) {
if (revision === cachedRundown?.revision) {
// we already have the latest change
return;
}
+35
View File
@@ -1,3 +1,5 @@
import { AppMode } from '../ontimeConfig';
declare module '*.scss' {
const content: Record<string, string>;
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
*/
@@ -53,7 +53,7 @@ ul {
&.active {
color: $ui-white;
border-left-color: $blue-400;
background-color: $bg-active;
background-color: rgba($blue-500, 0.16);
font-weight: 600;
}
@@ -79,7 +79,7 @@ ul {
&.active {
color: $ui-white;
border-left-color: $blue-400;
background-color: $bg-active;
background-color: rgba($blue-500, 0.16);
font-weight: 600;
}
}
@@ -124,7 +124,7 @@ $card-padding: 2rem;
// active rows use a panel-local class today; keep the treatment here so all
// settings tables share the same state without each panel owning its colours
tr[class*='current'] {
background-color: $bg-active;
background-color: rgba($blue-500, 0.16);
> td:first-child {
box-shadow: inset 3px 0 $blue-400;
@@ -4,8 +4,8 @@ import { IoTrashBin } from 'react-icons/io5';
import { deleteAllReport } from '../../../../common/api/report';
import { createBlob, downloadBlob } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import { useLoadedRundown } from '../../../../common/hooks-query/useLoadedRundown';
import useReport from '../../../../common/hooks-query/useReport';
import useRundown from '../../../../common/hooks-query/useRundown';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -15,7 +15,7 @@ import style from './ReportSettings.module.scss';
export default function ReportSettings() {
const { data: reportData } = useReport();
const { data } = useLoadedRundown();
const { data } = useRundown();
const clearReport = async () => await deleteAllReport();
const downloadCSV = (combinedReport: CombinedReport[]) => {
@@ -12,21 +12,20 @@ import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPres
import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import { viewLabels } from '../../../../../viewerConfig';
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from './URLPresetForm.module.scss';
const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.Cuesheet, label: viewLabels[OntimeView.Cuesheet] },
{ value: OntimeView.Operator, label: viewLabels[OntimeView.Operator] },
{ value: OntimeView.Timer, label: viewLabels[OntimeView.Timer] },
{ value: OntimeView.Backstage, label: viewLabels[OntimeView.Backstage] },
{ value: OntimeView.Timeline, label: viewLabels[OntimeView.Timeline] },
{ value: OntimeView.StudioClock, label: viewLabels[OntimeView.StudioClock] },
{ value: OntimeView.Countdown, label: viewLabels[OntimeView.Countdown] },
{ value: OntimeView.ProjectInfo, label: viewLabels[OntimeView.ProjectInfo] },
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
{ value: OntimeView.Operator, label: 'Operator' },
{ value: OntimeView.Timer, label: 'Timer' },
{ value: OntimeView.Backstage, label: 'Backstage' },
{ value: OntimeView.Timeline, label: 'Timeline' },
{ value: OntimeView.StudioClock, label: 'Studio Clock' },
{ value: OntimeView.Countdown, label: 'Countdown' },
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
];
const formId = 'url-preset-form';
@@ -19,8 +19,8 @@ import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { useDirectLinkToBackgroundEdit } from '../../../../common/hooks/useRundownSelection';
import { cx } from '../../../../common/utils/styleUtils';
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
import * as Panel from '../../panel-utils/PanelUtils';
import RundownRenameForm from './composite/RundownRenameForm';
import { ManageRundownForm } from './ManageRundownForm';
@@ -24,7 +24,7 @@ import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal';
import { useLoadedRundown } from '../../../../../common/hooks-query/useLoadedRundown';
import useRundown from '../../../../../common/hooks-query/useRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup';
@@ -59,7 +59,7 @@ export default function SourcesPanel() {
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useLoadedRundown();
const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
+30 -19
View File
@@ -3,7 +3,6 @@ import { useCallback, useState } from 'react';
import { IoClose } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
import ToggleButton from '../../common/components/buttons/ToggleButton';
import { clearLogs, useLogData } from '../../common/stores/logger';
import { cx } from '../../common/utils/styleUtils';
import * as Panel from '../app-settings/panel-utils/PanelUtils';
@@ -56,60 +55,72 @@ export default function Log() {
<div className={cx([style.container, isExtracted && style.extracted])}>
<Panel.InlineElements className={style.buttonBar}>
<span className={style.filterLabel}>Filter by</span>
<ToggleButton
pressed={showUser}
<Button
variant={showUser ? 'primary' : 'subtle'}
size='small'
aria-pressed={showUser}
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.User}
</ToggleButton>
<ToggleButton
pressed={showClient}
</Button>
<Button
variant={showClient ? 'primary' : 'subtle'}
size='small'
aria-pressed={showClient}
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Client}
</ToggleButton>
<ToggleButton
pressed={showServer}
</Button>
<Button
variant={showServer ? 'primary' : 'subtle'}
size='small'
aria-pressed={showServer}
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Server}
</ToggleButton>
<ToggleButton
pressed={showPlayback}
</Button>
<Button
variant={showPlayback ? 'primary' : 'subtle'}
size='small'
aria-pressed={showPlayback}
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Playback}
</ToggleButton>
<ToggleButton
pressed={showRx}
</Button>
<Button
variant={showRx ? 'primary' : 'subtle'}
size='small'
aria-pressed={showRx}
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Rx}
</ToggleButton>
<ToggleButton
pressed={showTx}
</Button>
<Button
variant={showTx ? 'primary' : 'subtle'}
size='small'
aria-pressed={showTx}
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Tx}
</ToggleButton>
</Button>
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear
</Button>
@@ -1,7 +1,7 @@
import { CustomFields, Rundown, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { ViewData, aggregateQueryStatus } from '../../views/utils/viewLoader.utils';
@@ -14,7 +14,7 @@ export interface OperatorData {
}
export function useOperatorData(): ViewData<OperatorData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useLoadedRundownWithMetadata();
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { data: settings, status: settingsStatus } = useSettings();
@@ -12,7 +12,7 @@ import {
} from 'react-icons/tb';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useLoadedEntry } from '../../../common/hooks-query/useLoadedRundown';
import { useEntry } from '../../../common/hooks-query/useRundown';
import { useAutoTickingClock } from '../../../common/hooks/useAutoTickingClock';
import {
useCurrentGroupId,
@@ -210,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback, phase } = useGroupTimerOverView();
const currentGroupId = useCurrentGroupId();
const group = useLoadedEntry(currentGroupId) as OntimeGroup | null;
const group = useEntry(currentGroupId) as OntimeGroup | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -266,7 +266,7 @@ function GroupTimes() {
function FlagTimes() {
const { clock, mode, actualStart, plannedStart, playback, currentDay, phase } = useFlagTimerOverView();
const { id, expectedStart } = useNextFlag();
const entry = useLoadedEntry(id) as OntimeEvent | null;
const entry = useEntry(id) as OntimeEvent | null;
const hasRunningTimer = phase !== TimerPhase.Pending && isPlaybackActive(playback);
@@ -1,12 +1,12 @@
import { useLoadedRundownAuxData } from '../../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { useRundownAuxData } from '../../../common/hooks-query/useRundown';
import style from './TitleOverview.module.scss';
export default function TitleOverview() {
'use memo';
const { data: projectData } = useProjectData();
const { data: rundownData } = useLoadedRundownAuxData();
const { data: rundownData } = useRundownAuxData();
const projectTitle = projectData.title.trim();
const rundownTitle = rundownData.title.trim();
+1 -5
View File
@@ -67,11 +67,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
const entryActions = useEntryActionsContext();
const setEntryCopy = useEntryCopy((state) => state.setEntryCopyId);
const setEntryCopyId = useCallback(
(entryId: EntryId | null, mode?: 'copy' | 'cut') => setEntryCopy(entryId, id, mode),
[setEntryCopy, id],
);
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
// cursor
const { editorMode } = useEditorFollowMode();
@@ -5,6 +5,9 @@ import * as Editor from '../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
@@ -36,25 +39,28 @@ function RundownExport() {
defaultValue: RundownViewMode.List,
});
const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) {
return (
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
data-target='small-device'
data-testid='panel-rundown'
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.rundown}>
<ErrorBoundary>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div
className={cx([style.rundownExport, style.extracted])}
data-target='small-device'
data-testid='panel-rundown'
>
<FinderPlacement />
<ViewNavigationMenu suppressSettings />
<div className={style.rundown}>
<ErrorBoundary>
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</div>
</div>
</div>
</ProtectRoute>
</ProtectRoute>
</EntryActionsProvider>
);
}
@@ -64,28 +70,30 @@ function RundownExport() {
viewMode === RundownViewMode.Table;
return (
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<Editor.Panel className={style.list}>
<ErrorBoundary>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement />
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<div className={style.rundown}>
<Editor.Panel className={style.list}>
<ErrorBoundary>
<RundownEntryEditor />
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</div>
)}
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</div>
</div>
</div>
</ProtectRoute>
</ProtectRoute>
</EntryActionsProvider>
);
}
@@ -97,6 +105,8 @@ interface RundownRootProps {
}
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
const source = useLoadedRundownSource();
return (
<div className={style.rundownRoot}>
{isSmallDevice ? (
@@ -105,7 +115,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal />}
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
<RenumberCuesDialog />
</div>
);
@@ -1,22 +1,15 @@
import { Playback } from 'ontime-types';
import { memo } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useTranslation } from '../../translation/TranslationProvider';
import Rundown from './Rundown';
/** a rundown which is not loaded has no playback to follow */
const idleFeatureData = { playback: Playback.Stop, selectedEventId: null, nextEventId: null };
export default memo(RundownList);
function RundownList() {
const { isLoaded } = useRundownScope();
const { data, status, rundownMetadata } = useRundownWithMetadata();
const runtimeFeatureData = useRundownEditor();
const featureData = isLoaded ? runtimeFeatureData : idleFeatureData;
const featureData = useRundownEditor();
const { getLocalizedString } = useTranslation();
// avoid showing the editable empty state before we know whether the rundown is actually empty
@@ -2,7 +2,6 @@ import { type EntryId, type OntimeEntry, type Rundown, SupportedEntry } from 'on
import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils';
import { useCallback } from 'react';
import { useRundownScope } from '../../../common/context/RundownScopeContext';
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { SelectionMode } from '../useEventSelection';
@@ -27,7 +26,6 @@ export function useRundownCommands({
selectEntry: applySelection,
handleCollapseGroup,
}: UseRundownCommandsOptions) {
const { rundownId } = useRundownScope();
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
const deleteAtCursor = useCallback(
@@ -45,17 +43,12 @@ export function useRundownCommands({
const insertCopyAtId = useCallback(
(atId: EntryId | null, above = false) => {
// lazily get the value from the store
const { entryCopyId, entryCopyRundownId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection
return;
}
// pasting into a different rundown needs the entry payload, the server clones within a rundown
if (entryCopyRundownId !== null && entryCopyRundownId !== rundownId) {
return;
}
let normalisedAtId = atId;
const elementToCopy = entries[entryCopyId];
@@ -72,7 +65,7 @@ export function useRundownCommands({
return;
}
reorderEntry(entryCopyId, firstId, 'before')
.then(() => setEntryCopyId(null, null))
.then(() => setEntryCopyId(null))
.catch(() => {});
return;
}
@@ -81,7 +74,7 @@ export function useRundownCommands({
}
const placement = above ? 'before' : 'after';
reorderEntry(entryCopyId, normalisedAtId, placement)
.then(() => setEntryCopyId(null, null))
.then(() => setEntryCopyId(null))
.catch(() => {});
return;
}
@@ -92,7 +85,7 @@ export function useRundownCommands({
before: above ? (normalisedAtId ?? undefined) : undefined,
});
},
[entries, flatOrder, clone, reorderEntry, rundownId],
[entries, flatOrder, clone, reorderEntry],
);
/**
@@ -8,16 +8,10 @@ export default memo(FinderPlacement);
function FinderPlacement() {
const [isOpen, handler] = useDisclosure();
/**
* The empty tagsToIgnore is significant: by default the hook skips input elements,
* which would make the shortcut dead while editing an entry.
*
* This opens rather than toggles. Toggling on a key that also mounts and unmounts the
* dialog races against it, and browsers treat a repeated find shortcut as "focus the
* search again" rather than "close it". The finder selects its input instead, and
* Escape closes.
*/
useHotkeys([['mod + f', handler.open, { preventDefault: true }]], []);
useHotkeys([
['mod + f', handler.toggle, { preventDefault: true }],
['Escape', handler.close, { preventDefault: true }],
]);
if (isOpen) {
return <Finder isOpen={isOpen} onClose={handler.close} />;
@@ -312,7 +312,6 @@ export default function RundownEvent({
onClick={handleFocusClick}
onContextMenu={onContextMenu}
data-testid='rundown-event'
data-selected={isSelected}
{...(isPlaying ? { 'data-running': true } : {})}
>
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
@@ -1,6 +1,9 @@
import { memo, useEffect, useMemo } from 'react';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -12,6 +15,8 @@ function RundownTable() {
const { data: customFields } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const source = useLoadedRundownSource();
const actions = useEntryActions();
// Editor always has full permissions
useEffect(() => {
@@ -27,8 +32,10 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
return (
<CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
</CuesheetDnd>
</EntryActionsProvider>
);
}
@@ -1,14 +1,15 @@
import type { ColumnDef } from '@tanstack/react-table';
import type { CustomFields } from 'ontime-types';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { AppMode } from '../../../ontimeConfig';
import { makeCuesheetColumns } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import type { CuesheetColumnDef } from '../../../views/cuesheet/cuesheet-table/cuesheetTable.features';
/**
* Creates column definitions for the rundown table
* Reuses cuesheetColsFactory with preset=undefined for full access
*/
export function makeRundownColumns(customFields: CustomFields): CuesheetColumnDef[] {
export function makeRundownColumns(customFields: CustomFields): ColumnDef<ExtendedEntry>[] {
// When preset=undefined, factory defaults to fullRead=true, fullWrite=true
// canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run)
return makeCuesheetColumns(customFields, AppMode.Edit, undefined);
@@ -1,15 +1,155 @@
import { useStore } from 'zustand';
import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } from 'ontime-types';
import { MouseEvent } from 'react';
import { create } from 'zustand';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import type { EventSelectionStore } from '../../common/stores/eventSelectionStore';
import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils';
export { getSelectionMode } from '../../common/stores/eventSelectionStore';
export type { SelectionMode } from '../../common/stores/eventSelectionStore';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
/**
* Selection and cursor for the rundown of the enclosing scope
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
*/
export function useEventSelection<T>(selector: (state: EventSelectionStore) => T): T {
const { selectionStore } = useRundownScope();
return useStore(selectionStore, selector);
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in single mode, we replace the selection and change the mode
if (entryMode === 'single') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = getLoadedRundownData();
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
selectedEvents.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = getLoadedRundownData();
if (!rundownData) return;
// get list of rundown with only ontime events
const eventIds: EntryId[] = [];
rundownData.flatOrder.forEach((eventId) => {
const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
eventIds.push(event.id);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({
selectedEvents: new Set(firstSelected ? [firstSelected] : []),
anchoredIndex: null,
entryMode: null,
});
},
unselect: (id: string) => {
const { entryMode, selectedEvents } = get();
selectedEvents.delete(id);
set({
selectedEvents,
entryMode: selectedEvents.size === 0 ? null : entryMode,
});
},
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
handler(id);
}
},
}));
function getLoadedRundownData() {
const rundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
if (!rundownId) return undefined;
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(rundownId));
}
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
}
@@ -1,7 +1,6 @@
import { EntryId, MaybeString } from 'ontime-types';
import { useCallback } from 'react';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEventSelection } from './useEventSelection';
@@ -11,8 +10,7 @@ type SelectAndRevealOptions = {
parent?: MaybeString;
};
export function useSelectAndRevealEntry() {
const { rundownId } = useRundownScope();
export function useSelectAndRevealEntry(rundownId: string) {
const { expandGroup } = useCollapsedGroups(rundownId);
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
-1
View File
@@ -48,7 +48,6 @@ $playback-under: $green-500;
// interface panels
$bg-container-l1: $gray-1350;
$bg-container-l2: $gray-1300;
$bg-active: rgba($action-blue, 0.16);
$backdrop-color: rgba(0, 0, 0, 0.7);
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
+7 -25
View File
@@ -1,30 +1,12 @@
import { OntimeView } from 'ontime-types';
/** User facing name of each view, the single source for view naming in the UI */
export const viewLabels: Record<OntimeView, string> = {
[OntimeView.Editor]: 'Editor',
[OntimeView.Cuesheet]: 'Cuesheet',
[OntimeView.Operator]: 'Operator',
[OntimeView.Timer]: 'Timer',
[OntimeView.Backstage]: 'Backstage',
[OntimeView.Timeline]: 'Timeline',
[OntimeView.StudioClock]: 'Studio Clock',
[OntimeView.Countdown]: 'Countdown',
[OntimeView.ProjectInfo]: 'Project Info',
};
/** Views offered in the navigation menu, in the order they are shown. Their value is also their route */
const navigatorViews = [
OntimeView.Timer,
OntimeView.Backstage,
OntimeView.Timeline,
OntimeView.StudioClock,
OntimeView.Countdown,
OntimeView.ProjectInfo,
export const navigatorConstants = [
{ url: 'timer', label: 'Timer' },
{ url: 'backstage', label: 'Backstage' },
{ url: 'timeline', label: 'Timeline' },
{ url: 'studio', label: 'Studio Clock' },
{ url: 'countdown', label: 'Countdown' },
{ url: 'info', label: 'Project Info' },
];
export const navigatorConstants = navigatorViews.map((view) => ({ url: view, label: viewLabels[view] }));
// default time format to use for users in 12 hour clocks
export const FORMAT_12 = 'h:mm:ss a';
// default time format to use for users in 24 hour clocks
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundown } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
@@ -20,7 +20,7 @@ export function useBackstageData(): ViewData<BackstageData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundown();
const { data: rundownData, status: rundownStatus } = useFlatRundown();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -12,7 +12,7 @@ import {
useState,
} from 'react';
import { useLoadedPartialRundown } from '../../../common/hooks-query/useLoadedRundown';
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { useScheduleOptions } from './schedule.options';
@@ -45,7 +45,7 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
[filter],
);
const { data: events } = useLoadedPartialRundown(filterCallback);
const { data: events } = usePartialRundown(filterCallback);
const [firstIndex, setFirstIndex] = useState(-1);
const [numPages, setNumPages] = useState(0);
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
@@ -21,7 +21,7 @@ export function useCountdownData(): ViewData<CountdownData> {
const isMirrored = useViewOptionsStore((state) => state.mirror);
// HTTP API data
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata();
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
@@ -3,31 +3,35 @@ import { IoApps } from 'react-icons/io5';
import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider';
import { useRundownSelection } from '../../common/hooks/useRundownSelection';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview';
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableWrapper from './CuesheetTableWrapper';
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() {
'use memo';
const [isMenuOpen, menuHandler] = useDisclosure();
const { scopedRundownId, selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } =
useRundownSelection('cuesheet');
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
const actions = useScopedEntryActions(source.rundownId);
useWindowTitle('Cuesheet');
const isLocked = getIsNavigationLocked();
return (
<EditableRundownScopeProvider rundownId={scopedRundownId}>
<EntryActionsProvider actions={actions}>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<EntryEditModal />
<EntryEditModal rundown={source.rundown} />
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetOverview>
{!isLocked && (
@@ -38,12 +42,13 @@ export default function CuesheetPage() {
</CuesheetOverview>
<CuesheetProgress />
<CuesheetTableWrapper
source={source}
selectedRundownId={selectedRundownId}
loadedRundownId={loadedRundownId}
setSelectedRundownId={setSelectedRundownId}
projectRundowns={projectRundowns}
/>
</div>
</EditableRundownScopeProvider>
</EntryActionsProvider>
);
}
@@ -3,18 +3,19 @@ import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import { PresetContext } from '../../common/context/PresetContext';
import { useRundownScope } from '../../common/context/RundownScopeContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { FOLLOW_LOADED_RUNDOWN_ID } from '../../common/hooks/useRundownSelection';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
import { AppMode } from '../../ontimeConfig';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable';
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
import styles from './CuesheetPage.module.scss';
interface CuesheetTableWrapperProps {
source: RundownSource;
selectedRundownId: MaybeString;
loadedRundownId: string;
setSelectedRundownId: (rundownId: string) => void;
@@ -23,14 +24,15 @@ interface CuesheetTableWrapperProps {
export default memo(CuesheetTableWrapper);
function CuesheetTableWrapper({
source,
selectedRundownId,
setSelectedRundownId,
loadedRundownId,
projectRundowns,
}: CuesheetTableWrapperProps) {
const preset = use(PresetContext);
const { isLoaded } = useRundownScope();
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoaded });
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
const { data: customFields } = useCustomFields();
const columns = useMemo(
@@ -42,9 +44,11 @@ function CuesheetTableWrapper({
<CuesheetDnd columns={columns}>
<CuesheetTable
columns={columns}
source={source}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
isCurrentRundown={isCurrentRundown}
insertElement={
<>
<RundownSelect
@@ -1,13 +1,13 @@
import {
FOLLOW_LOADED_RUNDOWN_ID,
getRundownSelectionStorageKey,
getCuesheetRundownStorageKey,
resolveSelectedRundownId,
} from '../useRundownSelection';
} from '../useCuesheetRundownSelection';
describe('useRundownSelection helpers', () => {
it('builds a namespace and project scoped storage key', () => {
expect(getRundownSelectionStorageKey('cuesheet', 'http://localhost:4001', 'My Project')).toBe(
'rundown-selection:cuesheet:http://localhost:4001:My Project',
describe('useCuesheetRundownSelection helpers', () => {
it('builds a project-scoped storage key', () => {
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
'cuesheet-selected-rundown:http://localhost:4001:My Project',
);
});
@@ -7,13 +7,14 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
import { ColumnDef } from '@tanstack/react-table';
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';
interface CuesheetDndProps {
columns: CuesheetColumnDef[];
columns: ColumnDef<ExtendedEntry>[];
tableRoot?: 'editor' | 'cuesheet';
}
@@ -1,13 +1,16 @@
import { Rundown } from 'ontime-types';
import { memo } from 'react';
import Modal from '../../../common/components/modal/Modal';
import useRundown from '../../../common/hooks-query/useRundown';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditModal } from './useEditModal';
interface EntryEditModalProps {
rundown: Rundown;
}
export default memo(EntryEditModal);
function EntryEditModal() {
const { data: rundown } = useRundown();
function EntryEditModal({ rundown }: EntryEditModalProps) {
const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection);
@@ -1,5 +1,5 @@
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 { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import {
@@ -14,8 +14,7 @@ import {
import EmptyFill from '../../../common/components/state/EmptyFill';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useRundownScope } from '../../../common/context/RundownScopeContext';
import { useFlatRundownWithMetadata, useScopedSelectedEventId } from '../../../common/hooks-query/useRundown';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
@@ -30,29 +29,27 @@ import GroupRow from './cuesheet-table-elements/GroupRow';
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
import TableMenu from './cuesheet-table-menu/TableMenu';
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
import {
CuesheetColumnDef,
CuesheetTable as CuesheetTableInstance,
cuesheetTableFeatures,
} from './cuesheetTable.features';
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = {
columns: CuesheetColumnDef[];
columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode;
source: RundownSource;
insertElement?: ReactNode;
};
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'editor';
setCuesheetMode?: undefined;
isCurrentRundown?: undefined;
};
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
tableRoot: 'cuesheet';
setCuesheetMode: (mode: AppMode) => void;
isCurrentRundown?: boolean;
};
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
@@ -60,13 +57,13 @@ type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
export default function CuesheetTable({
columns,
cuesheetMode,
source,
tableRoot,
setCuesheetMode,
isCurrentRundown,
insertElement,
}: CuesheetTableProps) {
const { isLoaded: isCurrentRundown } = useRundownScope();
const { data: flatRundown, status } = useFlatRundownWithMetadata();
const selectedEventId = useScopedSelectedEventId();
const { flatRundown, status, selectedEventId } = source;
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
const { getLocalizedString } = useTranslation();
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
@@ -123,8 +120,7 @@ export default function CuesheetTable({
const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot);
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
const table = useTable({
features: cuesheetTableFeatures,
const table = useReactTable({
data: flatRundown,
columns,
columnResizeMode: 'onChange',
@@ -135,6 +131,7 @@ export default function CuesheetTable({
},
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
meta,
});
@@ -198,7 +195,7 @@ export default function CuesheetTable({
return colSizes;
// eslint-disable-next-line react-compiler/react-compiler -- unfortunately this is what we need
// eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
}, [columns, table.state.columnResizing, table.state.columnSizing]);
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
const allLeafColumns = table.getAllLeafColumns();
const { rows } = table.getRowModel();
@@ -217,7 +214,9 @@ export default function CuesheetTable({
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
const fixedHeaderContent = useCallback(() => {
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
return (
@@ -280,8 +279,8 @@ interface CuesheetVirtuosoContext {
columnSizeVars: { [key: string]: number };
cursor: string | null;
listeners: ReturnType<typeof useTableNav>['listeners'];
rows: ReturnType<CuesheetTableInstance['getRowModel']>['rows'];
table: CuesheetTableInstance;
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
table: Table<ExtendedEntry>;
handleAddNew?: (type: SupportedEntry) => void;
}
@@ -1,16 +1,16 @@
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 type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig';
import type { CuesheetHeaderGroup } from '../cuesheetTable.features';
import { Draggable, SortableCell, TableCell } from './SortableCell';
import style from '../CuesheetTable.module.scss';
interface CuesheetHeaderProps {
headerGroup: CuesheetHeaderGroup;
headerGroup: HeaderGroup<ExtendedEntry>;
cuesheetMode: AppMode;
hideIndexColumn: boolean;
}
@@ -46,7 +46,7 @@ export function SortableCuesheetHeader({ headerGroup, cuesheetMode, hideIndexCol
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />}
>
{header.isPlaceholder ? null : <FlexRender header={header} />}
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
})}
@@ -85,7 +85,7 @@ export function CuesheetHeader({ headerGroup, cuesheetMode, hideIndexColumn }: C
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />}
>
{header.isPlaceholder ? null : <FlexRender header={header} />}
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableCell>
);
})}
@@ -1,14 +1,14 @@
import { FlexRender } from '@tanstack/react-table';
import { EntryId, RGBColour, SupportedEntry } from 'ontime-types';
import { Table, flexRender } from '@tanstack/react-table';
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties, memo, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './EventRow.module.scss';
@@ -25,7 +25,7 @@ interface EventRowProps {
skip: boolean;
parent: EntryId | null;
rowIndex: number;
table: CuesheetTable;
table: Table<ExtendedEntry<OntimeEntry>>;
injectedStyles?: CSSProperties;
hasCursor?: boolean;
}
@@ -133,7 +133,7 @@ function EventRow({
data-testid={`cuesheet-cell-${cell.column.id}`}
data-column-id={cell.column.id}
>
<FlexRender cell={cell} />
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</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 { CSSProperties, memo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './GroupRow.module.scss';
@@ -15,7 +15,7 @@ interface GroupRowProps {
colour: string;
rowId: string;
rowIndex: number;
table: CuesheetTable;
table: Table<ExtendedEntry>;
injectedStyles?: CSSProperties;
hasCursor?: boolean;
}
@@ -76,7 +76,7 @@ function GroupRow({
}}
role='cell'
>
<FlexRender cell={cell} />
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
@@ -1,14 +1,14 @@
import { FlexRender } from '@tanstack/react-table';
import { Table, flexRender } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties, memo, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './MilestoneRow.module.scss';
@@ -20,7 +20,7 @@ interface MilestoneRowProps {
colour: string;
rowId: string;
rowIndex: number;
table: CuesheetTable;
table: Table<ExtendedEntry>;
injectedStyles?: CSSProperties;
hasCursor?: boolean;
}
@@ -102,7 +102,7 @@ function MilestoneRow({
}}
tabIndex={-1}
>
{canRender && <FlexRender cell={cell} />}
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
@@ -1,8 +1,9 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { CSSProperties, ReactNode } from 'react';
import type { CuesheetHeaderCell } from '../cuesheetTable.features';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import style from '../CuesheetTable.module.scss';
@@ -47,7 +48,7 @@ export function TableCell({ colSpan, injectedStyles, children, draggable }: Sort
}
interface DraggableProps {
header: CuesheetHeaderCell;
header: Header<ExtendedEntry, unknown>;
}
export function Draggable({ header }: DraggableProps) {
@@ -1,3 +1,4 @@
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { useCallback } from 'react';
@@ -7,7 +8,6 @@ import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig';
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
import type { CuesheetCellContext, CuesheetColumnDef } from '../cuesheetTable.features';
import DurationInput from './DurationInput';
import EditableImage from './EditableImage';
import FlagCell from './FlagCell';
@@ -17,11 +17,11 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell';
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;
}
function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
if (!table.options.meta) {
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) {
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) {
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(
(newValue: string) => {
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(
(newValue: string) => {
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} />;
}
function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
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;
if (!isOntimeEvent(event) || !event.flag) {
return null;
@@ -215,7 +215,7 @@ function MakeFlagField({ row }: CuesheetCellContext) {
return <FlagCell />;
}
function MakeCustomField({ row, column, table }: CuesheetCellContext) {
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -255,8 +255,8 @@ export function makeCuesheetColumns(
customFields: CustomFields,
cuesheetMode: AppMode,
preset: URLPreset | undefined,
): CuesheetColumnDef[] {
const columnsDef: CuesheetColumnDef[] = [];
): ColumnDef<ExtendedEntry>[] {
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
if (canRead('flag')) {
@@ -2,6 +2,7 @@ import { Popover } from '@base-ui/react/popover';
import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group';
import { Toolbar } from '@base-ui/react/toolbar';
import type { Column } from '@tanstack/react-table';
import { ReactNode } from 'react';
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
@@ -9,9 +10,9 @@ import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetPermissions } from '../../useTablePermissions';
import type { CuesheetColumn } from '../cuesheetTable.features';
import CuesheetShareModal from './CuesheetShareModal';
import style from './CuesheetTableSettings.module.scss';
@@ -36,7 +37,7 @@ type TableModeControls = {
};
interface CuesheetTableHeaderToolbarProps {
columns: CuesheetColumn[];
columns: Column<ExtendedEntry, unknown>[];
optionsStore: TableHeaderOptionsStore;
handleResetResizing: () => void;
handleResetReordering: () => void;
@@ -110,7 +111,7 @@ interface ViewSettingsProps {
}
interface ColumnSettingsProps {
columns: CuesheetColumn[];
columns: Column<ExtendedEntry, unknown>[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
@@ -202,7 +203,7 @@ function ColumnSettings({
return (
<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}
</Editor.Label>
);
@@ -12,7 +12,7 @@
transition: background-color 0.2s ease-in-out;
&[data-background-rundown='true'] {
background-color: $bg-active;
background-color: rgba($ontime-color, 0.5);
.apart::before {
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 { ColumnSizingState, Updater } from '@tanstack/react-table';
import { ColumnDef, ColumnSizingState, Updater } from '@tanstack/react-table';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { debounce } from '../../../common/utils/debounce';
import { makeStageKey } from '../../../common/utils/localStorage';
import type { CuesheetColumnDef } from './cuesheetTable.features';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
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 [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
@@ -2,21 +2,14 @@ import { useSessionStorage } from '@mantine/hooks';
import { startTransition, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
import { serverURL } from '../../externals';
import { useOrderedProjectList } from '../hooks-query/useProjectList';
import { useProjectRundowns } from '../hooks-query/useProjectRundowns';
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
/** each surface keeps its own selection, so panels can point at different rundowns */
export type RundownSelectionNamespace = 'cuesheet';
export function getRundownSelectionStorageKey(
namespace: RundownSelectionNamespace,
server: string,
projectFilename: string,
) {
return `rundown-selection:${namespace}:${server}:${projectFilename}`;
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
}
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
@@ -24,22 +17,14 @@ export function resolveSelectedRundownId(storedSelectedRundownId: string | null,
return FOLLOW_LOADED_RUNDOWN_ID;
}
/**
* Persisted choice of which rundown a surface operates on.
* The resolved id is meant to be handed to a rundown scope provider,
* where null means follow whichever rundown is loaded.
*/
export function useRundownSelection(namespace: RundownSelectionNamespace) {
export function useCuesheetRundownSelection() {
'use memo';
const { data: projectRundowns } = useProjectRundowns();
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
const storageKey = useMemo(
() => getRundownSelectionStorageKey(namespace, serverURL, lastLoadedProject),
[namespace, lastLoadedProject],
);
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
key: storageKey,
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
@@ -53,8 +38,6 @@ export function useRundownSelection(namespace: RundownSelectionNamespace) {
return {
loadedRundownId,
selectedRundownId,
/** id for the rundown scope, null follows the loaded rundown */
scopedRundownId: selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? null : selectedRundownId,
projectRundowns: projectRundowns.rundowns,
setSelectedRundownId: (rundownId: string) => {
startTransition(() => {
@@ -69,7 +52,7 @@ export function useDirectLinkToBackgroundEdit() {
data: { lastLoadedProject },
} = useOrderedProjectList();
const navigate = useNavigate();
const storageKey = getRundownSelectionStorageKey('cuesheet', serverURL, lastLoadedProject);
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
return useCallback(
+15 -23
View File
@@ -1,6 +1,5 @@
import { lazy } from 'react';
import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider';
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
import { AppMode } from '../../ontimeConfig';
import TitleList from './title-list/TitleList';
@@ -22,9 +21,7 @@ export default function Editor() {
<TimerControl />
<MessageControl />
</div>
<EditableRundownScopeProvider rundownId={null}>
<Rundown />
</EditableRundownScopeProvider>
<Rundown />
</div>
);
}
@@ -32,17 +29,14 @@ export default function Editor() {
if (layoutMode === EditorLayoutMode.TRACKING) {
return (
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
{/* the titles and the rundown share a scope, so they follow one cursor */}
<EditableRundownScopeProvider rundownId={null}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Run} />
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Run} />
</div>
</EditableRundownScopeProvider>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
<TrackingPlaybackBar />
</div>
);
@@ -50,16 +44,14 @@ export default function Editor() {
return (
<div id='panels' className={styles.panelContainer}>
<EditableRundownScopeProvider rundownId={null}>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Edit} />
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Edit} />
</div>
</EditableRundownScopeProvider>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
</div>
);
}
@@ -3,14 +3,11 @@
.error {
padding-inline: 0.5rem;
font-size: 1rem;
// rows grow when a match is shown from a note or custom field
min-height: 3rem;
padding-block: 0.35rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.entry[data-selected='true'] {
@@ -21,47 +18,21 @@
color: $label-gray;
}
.more {
padding-inline: 0.5rem;
padding-block: 0.75rem;
font-size: calc(1rem - 2px);
color: $label-gray;
border-top: 1px solid $gray-1000;
text-align: center;
}
.error {
color: $error-red;
}
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding-top: 0.75rem;
}
.filterLabel {
font-size: calc(1rem - 3px);
color: $label-gray;
margin-right: 0.15rem;
}
.data {
display: grid;
grid-template-areas:
'index cue'
'index title'
'index match';
'index title';
column-gap: 1rem;
grid-template-rows: min-content 1fr;
min-width: 0;
.index {
grid-area: index;
// background and text colour come from getAccessibleColour, which keeps the
// number legible whatever colour the user gave the entry
background-color: var(--color, $gray-1000);
border-radius: 2px;
padding-block: 0.25rem;
width: 3.5rem;
@@ -71,33 +42,14 @@
.title {
grid-area: title;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cue {
grid-area: cue;
font-size: calc(1rem - 2px);
color: $label-gray;
max-height: 1em;
min-height: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.match {
grid-area: match;
font-size: calc(1rem - 3px);
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.matchLabel {
color: $ui-white;
margin-right: 0.4rem;
}
}
@@ -111,14 +63,13 @@
color: $label-gray;
}
.count {
.filterHint {
text-align: right;
white-space: nowrap;
}
.go {
white-space: nowrap;
padding-left: 1rem;
.em {
color: $ui-white;
margin-inline: 0.25rem;
}
.hints {
@@ -147,7 +98,7 @@
flex-direction: column;
}
.count {
.filterHint {
text-align: left;
}
}
+52 -117
View File
@@ -1,12 +1,11 @@
import { MaybeString } from 'ontime-types';
import { KeyboardEvent, useDeferredValue, useEffect, useRef, useState } from 'react';
import { useDebouncedCallback } from '@mantine/hooks';
import { SupportedEntry } from 'ontime-types';
import { KeyboardEvent, useState } from 'react';
import ToggleButton from '../../../common/components/buttons/ToggleButton';
import Input from '../../../common/components/input/input/Input';
import Kbd from '../../../common/components/kbd/Kbd';
import Modal from '../../../common/components/modal/Modal';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import useFinder, { FinderResult } from './useFinder';
import useFinder from './useFinder';
import style from './Finder.module.scss';
@@ -16,76 +15,46 @@ interface FinderProps {
}
export default function Finder({ isOpen, onClose }: FinderProps) {
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<MaybeString>(null);
const [selectedId, setSelectedId] = useState<MaybeString>(null);
const { find, select, results, error } = useFinder();
const [selected, setSelected] = useState(0);
/**
* Keeps typing responsive while the list re-renders.
* The search itself is cheap, rendering the results is what costs.
*/
const deferredSearch = useDeferredValue(search);
const { select, results, error, total, filters, appliedFilter } = useFinder(deferredSearch, filter);
const inputRef = useRef<HTMLInputElement>(null);
const activeRef = useRef<HTMLLIElement>(null);
/**
* We track the selection by ID so that it survives the result list changing under us:
* an entry that no longer exists falls back to the first result instead of dangling past the end
*/
const activeIndex = Math.max(
0,
results.findIndex((entry) => entry.id === selectedId),
);
const activeEntry = results.at(activeIndex);
/** keep the highlighted entry in view while navigating with the keyboard */
useEffect(() => {
activeRef.current?.scrollIntoView({ block: 'nearest' });
}, [activeEntry?.id]);
const debouncedFind = useDebouncedCallback(find, 100);
const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
// pressing the search shortcut again selects the query, ready to be replaced
if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
event.preventDefault();
inputRef.current?.select();
return;
}
// all operations need results
if (results.length === 0) {
return;
}
if (event.key === 'ArrowDown') {
setSelectedId(results[(activeIndex + 1) % results.length].id);
setSelected((prev) => (prev + 1) % results.length);
}
if (event.key === 'ArrowUp') {
setSelectedId(results[(activeIndex - 1 + results.length) % results.length].id);
setSelected((prev) => (prev - 1 + results.length) % results.length);
}
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
submit(activeEntry);
submit();
}
};
const submit = (entry: FinderResult | undefined) => {
if (!entry) {
return;
}
select(entry);
const submit = () => {
const selectedEvent = results[selected];
select(selectedEvent);
onClose();
};
/** Scopes the search to a single field, or back to all fields when tapped again */
const handleFilter = (filterKey: string) => {
setFilter((previous) => (previous === filterKey ? null : filterKey));
inputRef.current?.focus();
const handleMouseMoveEvent = (event: React.MouseEvent<HTMLUListElement>) => {
const target = event.target as HTMLElement;
const li = target.closest('li');
if (li) {
const index = Number(li.dataset.index);
if (!isNaN(index)) {
setSelected(index);
}
}
};
const hiddenResults = total - results.length;
return (
<Modal
title=''
@@ -94,68 +63,35 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
showBackdrop
bodyElements={
<div onKeyDown={navigate}>
<Input
ref={inputRef}
height='large'
fluid
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder='Search...'
/>
<div className={style.filters} data-testid='finder-filters'>
<span className={style.filterLabel}>Filter by</span>
{filters.map((option) => (
<ToggleButton
key={option.key}
pressed={appliedFilter === option.key}
size='small'
onClick={() => handleFilter(option.key)}
>
{option.label}
</ToggleButton>
))}
</div>
<ul className={style.scrollContainer}>
<Input height='large' fluid onChange={debouncedFind} placeholder='Search...' />
<ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}>
{error && <li className={style.error}>{error}</li>}
{!error && results.length === 0 && <li className={style.empty}>No results</li>}
{results.map((entry) => {
const isSelected = activeEntry?.id === entry.id;
// the title and cue are already on the row, a match anywhere else needs showing
const showMatch = entry.match !== null && entry.match.key !== 'title' && entry.match.key !== 'cue';
{results.length === 0 && <li className={style.empty}>No results</li>}
{results.length > 0 &&
results.map((entry, index) => {
const isSelected = selected === index;
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
const displayCue = 'cue' in entry ? entry.cue : '';
return (
<li
key={entry.id}
ref={isSelected ? activeRef : undefined}
className={style.entry}
data-testid='finder-result'
data-selected={isSelected}
onClick={() => submit(entry)}
onPointerEnter={() => setSelectedId(entry.id)}
>
<div className={style.data}>
<div className={style.index} style={getAccessibleColour(entry.colour)}>
{entry.eventIndex ?? '-'}
</div>
<div className={style.cue}>{entry.cue}</div>
<div className={style.title}>{entry.title}</div>
{showMatch && (
<div className={style.match} data-testid='finder-result-match'>
<span className={style.matchLabel}>{entry.match?.label}</span>
{entry.match?.excerpt}
return (
<li
key={entry.id}
className={style.entry}
data-selected={isSelected}
data-index={index}
onClick={submit}
>
<div className={style.data}>
<div className={style.index} style={{ '--color': entry.colour }}>
{displayIndex}
</div>
)}
</div>
{isSelected && <span className={style.go}>Go </span>}
</li>
);
})}
{hiddenResults > 0 && (
<li className={style.more} data-testid='finder-more'>
{hiddenResults} more {hiddenResults === 1 ? 'result' : 'results'} keep typing to narrow the search
</li>
)}
<div className={style.cue}>{displayCue}</div>
<div className={style.title}>{entry.title}</div>
</div>
{isSelected && <span>Go </span>}
</li>
);
})}
</ul>
</div>
}
@@ -176,11 +112,10 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
Close
</span>
</div>
{total > 0 && (
<div className={style.count} data-testid='finder-count'>
{hiddenResults > 0 ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`}
</div>
)}
<div className={style.filterHint}>
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
<span className={style.em}>title</span>
</div>
</div>
}
/>
@@ -1,162 +0,0 @@
import { CustomFields, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone, SupportedEntry } from 'ontime-types';
import { parseQuery, searchByIndex, searchByText } from './useFinder';
function makeEvent(id: string, overrides: Partial<OntimeEvent> = {}): OntimeEvent {
return {
type: SupportedEntry.Event,
id,
cue: '',
title: '',
note: '',
colour: '#000000',
custom: {},
parent: null,
...overrides,
} as OntimeEvent;
}
function makeGroup(id: string, overrides: Partial<OntimeGroup> = {}): OntimeGroup {
return {
type: SupportedEntry.Group,
id,
title: '',
note: '',
colour: '#000000',
custom: {},
...overrides,
} as OntimeGroup;
}
function makeMilestone(id: string, overrides: Partial<OntimeMilestone> = {}): OntimeMilestone {
return {
type: SupportedEntry.Milestone,
id,
cue: '',
title: '',
note: '',
colour: '#000000',
custom: {},
parent: null,
...overrides,
} as OntimeMilestone;
}
function makeDelay(id: string): OntimeDelay {
return { type: SupportedEntry.Delay, id, duration: 1000, parent: null };
}
describe('parseQuery()', () => {
const filters = [
{ key: 'cue', label: 'Cue' },
{ key: 'Camera_Notes', label: 'Camera Notes' },
];
it.each([
['cue 12', { filterKey: 'cue', searchString: '12' }],
['cue:12', { filterKey: 'cue', searchString: '12' }],
['camera_notes:wide', { filterKey: 'Camera_Notes', searchString: 'wide' }],
])('parses the field prefix in %s', (searchValue, expected) => {
expect(parseQuery(searchValue, filters)).toStrictEqual(expected);
});
it('keeps an unprefixed query as a search across all fields', () => {
expect(parseQuery('zebrafish', filters)).toStrictEqual({ filterKey: null, searchString: 'zebrafish' });
});
it('recognises a filter before any search text has been entered', () => {
expect(parseQuery('cue', filters)).toStrictEqual({ filterKey: 'cue', searchString: '' });
});
});
describe('searchByText()', () => {
const customFields: CustomFields = {
Camera_Notes: { type: 'text', label: 'Camera Notes', colour: '#000000' },
Slide: { type: 'image', label: 'Slide', colour: '#000000' },
};
it('searches cue, title, note, and text custom fields in rundown order', () => {
const data = [
makeMilestone('milestone', { cue: 'needle' }),
makeGroup('group', { title: 'needle' }),
makeEvent('note', { note: 'find the needle here' }),
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
];
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results.map(({ id, match }) => ({ id, field: match?.key }))).toStrictEqual([
{ id: 'milestone', field: 'cue' },
{ id: 'group', field: 'title' },
{ id: 'note', field: 'note' },
{ id: 'custom', field: 'Camera_Notes' },
]);
expect(outcome.total).toBe(4);
});
it('searches only the selected field', () => {
const data = [
makeEvent('title', { title: 'needle' }),
makeEvent('note', { note: 'needle' }),
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
];
const outcome = searchByText(data, customFields, 'note', 'needle');
expect(outcome.results.map((result) => result.id)).toStrictEqual(['note']);
expect(outcome.total).toBe(1);
});
it('reports the first matching field so the result can explain why it matched', () => {
const data = [makeEvent('event', { cue: 'NEEDLE', title: 'another needle' })];
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results[0].match).toStrictEqual({ key: 'cue', label: 'Cue', excerpt: 'NEEDLE' });
});
it('does not search image custom fields', () => {
const data = [makeEvent('image-only', { custom: { Slide: 'needle' } })];
expect(searchByText(data, customFields, null, 'needle')).toStrictEqual({ results: [], error: null, total: 0 });
});
it('reports the full match count while limiting rendered results', () => {
const data = Array.from({ length: 51 }, (_, index) => makeEvent(String(index), { title: 'needle' }));
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results).toHaveLength(50);
expect(outcome.total).toBe(51);
});
});
describe('searchByIndex()', () => {
it('counts only events while preserving the flat rundown position', () => {
const data = [
makeGroup('group'),
makeDelay('delay'),
makeEvent('first'),
makeMilestone('milestone'),
makeEvent('second'),
];
const outcome = searchByIndex(data, '2');
expect(outcome.results).toHaveLength(1);
expect(outcome.results[0]).toMatchObject({ id: 'second', index: 4, eventIndex: 2 });
expect(outcome.total).toBe(1);
});
it.each(['0', 'not-a-number'])('rejects invalid index %s', (index) => {
expect(searchByIndex([makeEvent('event')], index)).toStrictEqual({
results: [],
error: 'Invalid index',
total: 0,
});
});
it('returns no result when the event index is beyond the rundown', () => {
expect(searchByIndex([makeEvent('event')], '2')).toStrictEqual({ results: [], error: null, total: 0 });
});
});
+201 -221
View File
@@ -1,259 +1,239 @@
import {
CustomFields,
EntryId,
MaybeNumber,
MaybeString,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
isOntimeDelay,
isOntimeEvent,
} from 'ontime-types';
import { useCallback, useMemo } from 'react';
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
/** How many results we render, the total number of matches is reported separately */
const maxResults = 50;
/** Notes can hold a whole script, we only show enough to explain the match */
const excerptPadding = 40;
const maxResults = 12;
const indexFilter = 'index';
/** Everything except delays, which carry no text to search */
type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone;
type FinderFilter = { key: string; label: string };
/**
* Offered to the user as filter badges. Index is a positional lookup rather than a
* text field, so it is handled separately from the fields a search runs over.
*/
const staticFilters: FinderFilter[] = [
{ key: indexFilter, label: 'Index' },
{ key: 'cue', label: 'Cue' },
{ key: 'title', label: 'Title' },
{ key: 'note', label: 'Note' },
];
/** Why an entry matched, so the UI can show the user */
type FinderMatch = { key: string; label: string; excerpt: string };
export type FinderResult = {
type FilterableGroup = {
type: SupportedEntry.Group;
id: EntryId;
/** position in the flat rundown, which is how the rundown reveals an entry */
index: number;
/** 1-based position among events, null for groups and milestones */
eventIndex: MaybeNumber;
title: string;
/** groups have no cue */
colour: string;
};
type FilterableEvent = {
type: SupportedEntry.Event;
id: EntryId;
index: number;
eventIndex: number;
title: string;
cue: string;
colour: string;
parent: MaybeString;
/** absent when the entry was found by index rather than by matching text */
match: FinderMatch | null;
};
type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: number };
type FilterableMilestone = {
type: SupportedEntry.Milestone;
id: EntryId;
index: number;
title: string;
cue: string;
colour: string;
parent: MaybeString;
};
const noResults: SearchOutcome = { results: [], error: null, total: 0 };
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone;
/** Groups are the only searchable entry with neither a cue nor a parent */
function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber, match: FinderMatch | null) {
return {
id: entry.id,
index,
eventIndex,
title: entry.title,
cue: 'cue' in entry ? entry.cue : '',
colour: entry.colour,
parent: 'parent' in entry ? entry.parent : null,
match,
} satisfies FinderResult;
}
export default function useFinder() {
const { data, rundownId } = useFlatRundown();
const [results, setResults] = useState<FilterableEntry[]>([]);
const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef('');
/** Shows enough of a long value for the user to see why it matched */
function makeExcerpt(value: string, matchIndex: number, searchLength: number): string {
const start = Math.max(0, matchIndex - excerptPadding);
const end = Math.min(value.length, matchIndex + searchLength + excerptPadding);
return `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`;
}
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
/**
* The first field of an entry to contain the search string, if any.
* Fields are tried in the order we prefer to report a match.
*/
function findMatch(
entry: SearchableEntry,
customFields: CustomFields,
filterKey: MaybeString,
searchString: string,
): FinderMatch | null {
function check(key: string, label: string, value: string): FinderMatch | null {
if (!value || (filterKey !== null && key !== filterKey)) {
return null;
}
const matchIndex = value.toLowerCase().indexOf(searchString);
if (matchIndex === -1) {
return null;
}
return { key, label, excerpt: makeExcerpt(value, matchIndex, searchString.length) };
}
/** Filters the rundown to a given evaluation */
const find = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
if (!data || data.length === 0) {
setError('No data');
return;
}
setError(null);
// groups have no cue, the rest is common to every searchable entry
const fromCue = 'cue' in entry ? check('cue', 'Cue', entry.cue) : null;
const match = fromCue ?? check('title', 'Title', entry.title) ?? check('note', 'Note', entry.note);
if (match !== null) {
return match;
}
if (event.target.value === '') {
setResults([]);
return;
}
// custom fields are named by the project, so these can only be reached generically
for (const [key, value] of Object.entries(entry.custom)) {
const definition = customFields[key];
if (definition?.type !== 'text') {
continue;
}
const custom = check(key, definition.label || key, value);
if (custom) return custom;
}
const searchValue = event.target.value.toLowerCase();
lastSearchString.current = searchValue;
return null;
}
if (searchValue.startsWith('index ')) {
const searchString = searchValue.slice('index '.length).trim();
const { results, error } = searchByIndex(searchString);
setResults(results);
setError(error);
return;
}
/**
* Splits the raw search value into an optional field filter and the text to look for.
* Both `cue 12` and `cue:12` are accepted so that typing agrees with the filter badges.
*/
export function parseQuery(searchValue: string, filters: FinderFilter[]) {
for (const filter of filters) {
// the search value is already lowercased, custom field keys are not
const prefix = filter.key.toLowerCase();
if (searchValue === prefix) {
return { filterKey: filter.key, searchString: '' };
}
if (searchValue.startsWith(`${prefix} `) || searchValue.startsWith(`${prefix}:`)) {
return { filterKey: filter.key, searchString: searchValue.slice(prefix.length + 1).trim() };
}
}
return { filterKey: null, searchString: searchValue };
}
if (searchValue.startsWith('cue ')) {
const searchString = searchValue.slice('cue '.length).trim();
const { results, error } = searchByCue(searchString);
setResults(results);
setError(error);
return;
}
/** Finds the single event at a 1-based position in the rundown */
export function searchByIndex(data: OntimeEntry[], indexString: string): SearchOutcome {
const target = Number(indexString);
if (isNaN(target) || target < 1) {
return { ...noResults, error: 'Invalid index' };
}
const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue;
const { results, error } = searchByTitle(searchString);
setResults(results);
setError(error);
let eventIndex = 0;
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (!isOntimeEvent(entry)) {
continue;
}
eventIndex++;
if (eventIndex === target) {
return { results: [toResult(entry, i, eventIndex, null)], error: null, total: 1 };
}
}
/** Returns a single item with a matching index */
function searchByIndex(searchString: string) {
const searchIndex = Number(searchString);
if (isNaN(searchIndex) || searchIndex < 1) {
return { results: [], error: 'Invalid index' };
}
return noResults;
}
if (searchIndex > data.length) {
return { results: [], error: null };
}
/**
* Matches entries on a single field when one is selected, otherwise on every text field.
* Results keep rundown order, which keeps them predictable during a show.
*/
export function searchByText(
data: OntimeEntry[],
customFields: CustomFields,
filterKey: MaybeString,
searchString: string,
): SearchOutcome {
const results: FinderResult[] = [];
let total = 0;
// indexes exposed to the UI are 1-based
let eventIndex = 0;
// indexes exposed to the UI are 1-based
let eventIndex = 1;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
const event = data[i];
if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) {
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
break;
}
eventIndex++;
}
}
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (isOntimeDelay(entry)) {
continue;
}
const isEvent = isOntimeEvent(entry);
if (isEvent) {
eventIndex++;
}
return { results, error: null };
}
const match = findMatch(entry, customFields, filterKey, searchString);
if (match === null) {
continue;
}
/** Returns maxResults of OntimeEvents that match the cue field */
function searchByCue(searchString: string) {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEvent[] = [];
total++;
if (results.length < maxResults) {
results.push(toResult(entry, i, isEvent ? eventIndex : null, match));
}
}
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.cue.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
}
eventIndex++;
}
}
return { results, error: null };
}
return { results, error: null, total };
}
/** Returns maxResults of OntimeEvents that match the title field*/
function searchByTitle(searchString: string) {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEntry[] = [];
/**
* @param searchValue - the text the user is looking for
* @param activeFilter - a field selected from the filter badges, if any
*/
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
const { data } = useFlatRundown();
const { data: customFields } = useCustomFields();
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const selectAndRevealEntry = useSelectAndRevealEntry();
/** The filters offered to the user: the fixed fields plus whatever the project defines */
const filters = useMemo<FinderFilter[]>(() => {
const customFilters = Object.entries(customFields)
.filter(([_key, field]) => field.type === 'text')
.map(([key, field]) => ({ key, label: field.label || key }));
return [...staticFilters, ...customFilters];
}, [customFields]);
const { results, error, total, appliedFilter } = useMemo(() => {
if (data.length === 0) {
return { ...noResults, error: 'No data', appliedFilter: activeFilter };
}
const normalised = searchValue.trim().toLowerCase();
if (normalised === '') {
return { ...noResults, appliedFilter: activeFilter };
}
/**
* If a badge is selected it scopes the search; otherwise, keyword prefixes in the input
* (e.g. "cue:" / "title:") are parsed and the matching badge is highlighted.
*/
const { filterKey, searchString } = activeFilter
? { filterKey: activeFilter, searchString: normalised }
: parseQuery(normalised, filters);
if (filterKey === indexFilter) {
return { ...searchByIndex(data, searchString), appliedFilter: filterKey };
}
if (searchString === '') {
// a filter is selected, but there is nothing to match on yet
return { ...noResults, appliedFilter: filterKey };
}
return { ...searchByText(data, customFields, filterKey, searchString), appliedFilter: filterKey };
}, [data, customFields, filters, searchValue, activeFilter]);
const entry = data[i];
if (isOntimeEvent(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: entry.id,
index: i,
eventIndex,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableEvent);
}
eventIndex++;
} else if (isOntimeGroup(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Group,
id: entry.id,
index: i,
title: entry.title,
colour: entry.colour,
} satisfies FilterableGroup);
}
} else if (isOntimeMilestone(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Milestone,
id: entry.id,
index: i,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableMilestone);
}
}
}
return { results, error: null };
}
},
[data],
);
const select = useCallback(
(result: FinderResult) => {
selectAndRevealEntry({ id: result.id, index: result.index, parent: result.parent });
(selectedEvent: FilterableEntry) => {
selectAndRevealEntry({
id: selectedEvent.id,
index: selectedEvent.index,
parent: 'parent' in selectedEvent ? selectedEvent.parent : null,
});
},
[selectAndRevealEntry],
);
return { select, results, error, total, filters, appliedFilter };
/** clear results when source data changes */
useEffect(() => {
setResults([]);
setError(null);
// fake a submit event to re-run the search
if (lastSearchString.current) {
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
}
}, [data, find]);
return { find, select, results, error };
}
@@ -46,6 +46,7 @@ export default function TitleList({ mode }: TitleListProps) {
eventData={eventData}
selectedEventId={selectedEventId}
resolvedFollowEventId={resolvedFollowEventId}
rundownId={rundown.id}
/>
);
}
@@ -55,14 +56,21 @@ interface TitleListContentProps {
eventData: ExtendedEntry<OntimeEvent>[];
selectedEventId: string | null;
resolvedFollowEventId: string | null;
rundownId: string;
}
function TitleListContent({ mode, eventData, selectedEventId, resolvedFollowEventId }: TitleListContentProps) {
function TitleListContent({
mode,
eventData,
selectedEventId,
resolvedFollowEventId,
rundownId,
}: TitleListContentProps) {
'use memo';
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const scrollParentRef = useRef<HTMLDivElement | null>(null);
const selectAndRevealEntry = useSelectAndRevealEntry();
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
// Calculate current event info
const currentEventInfo = useMemo(() => {
@@ -1,8 +1,8 @@
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedFlatRundownWithMetadata } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
@@ -16,7 +16,7 @@ export interface TimelineData {
export function useTimelineData(): ViewData<TimelineData> {
// HTTP API data
const { data: rundownData, status: rundownStatus } = useLoadedFlatRundownWithMetadata();
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
-18
View File
@@ -195,24 +195,6 @@
font-weight: 600;
}
.sound-prompt {
position: absolute;
bottom: $view-block-padding;
left: $view-inline-padding;
padding: 0.5em 0.75em;
border-radius: $element-border-radius;
background-color: $viewer-card-bg-color;
color: $viewer-secondary-color;
font-size: $timer-label-size;
text-transform: uppercase;
pointer-events: none;
transition: opacity $viewer-transition-time;
&--hidden {
opacity: 0;
}
}
/* =================== LOGO ===================*/
.logo {
position: absolute;
-17
View File
@@ -8,7 +8,6 @@ import TitleCard from '../../common/components/title-card/TitleCard';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useTimerSocket } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils';
@@ -31,7 +30,6 @@ import {
getTotalTime,
} from './timer.utils';
import { TimerData, useTimerData } from './useTimerData';
import { useTimerSound } from './useTimerSound';
import './Timer.scss';
@@ -68,7 +66,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
freezeOvertime,
freezeMessage,
hidePhase,
endSound,
font,
keyColour,
timerColour,
@@ -78,8 +75,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
const { getLocalizedString } = useTranslation();
const localisedMinutes = getLocalizedString('common.minutes');
const showSoundPrompt = useTimerSound(time.phase, endSound);
// gather modifiers
const viewTimerType = timerType ?? timerTypeNow;
const showOverlay = getShowMessage(message.timer);
@@ -161,8 +156,6 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
{showSoundPrompt && <SoundPermissionPrompt />}
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
{!hideMessage && (
@@ -234,13 +227,3 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
</div>
);
}
function SoundPermissionPrompt() {
const isUserActive = useFadeOutOnInactivity(true);
return (
<div className={cx(['sound-prompt', !isUserActive && 'sound-prompt--hidden'])} aria-live='polite'>
Interact with the page (click/tap or press any key) to enable sound
</div>
);
}
@@ -1,34 +0,0 @@
import { TimerPhase } from 'ontime-types';
import { shouldPlayEndSound } from '../timer.utils';
describe('shouldPlayEndSound()', () => {
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
'sounds when a running timer goes into overtime from %s',
(previousPhase) => {
expect(shouldPlayEndSound(previousPhase, TimerPhase.Overtime)).toBe(true);
},
);
it('stays silent on the first phase we see, a client could be joining mid-overtime', () => {
expect(shouldPlayEndSound(null, TimerPhase.Overtime)).toBe(false);
});
it('stays silent when the phase was reset, a reload during overtime starts from none', () => {
expect(shouldPlayEndSound(TimerPhase.None, TimerPhase.Overtime)).toBe(false);
});
it('stays silent for a roll timer waiting to start', () => {
expect(shouldPlayEndSound(TimerPhase.Pending, TimerPhase.Overtime)).toBe(false);
});
it('sounds once, not on every update while in overtime', () => {
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.Overtime)).toBe(false);
});
it('stays silent on phases which are not the end of the timer', () => {
expect(shouldPlayEndSound(TimerPhase.Default, TimerPhase.Warning)).toBe(false);
expect(shouldPlayEndSound(TimerPhase.Warning, TimerPhase.Danger)).toBe(false);
expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.None)).toBe(false);
});
});
+1 -14
View File
@@ -16,7 +16,6 @@ import { ViewOption } from '../../common/components/view-params-editor/viewParam
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean, makeColourString } from '../common/viewUtils';
import { endSoundOptions, isEndSound, type EndSound } from './timer.sound';
// manually match the properties of TimerType excluding the None
const timerDisplayOptions: SelectOption[] = [
@@ -77,15 +76,6 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
type: 'boolean',
defaultValue: false,
},
{
id: 'endSound',
title: 'Play sound on timer end',
description:
'Plays a sound in this screen when the timer reaches zero. The screen must be interacted with once before it can play',
type: 'option',
values: endSoundOptions,
defaultValue: 'none',
},
],
},
{
@@ -179,7 +169,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
{
id: 'timerColour',
title: 'Timer Colour',
description: 'Timer colour. Default: #F6F6F6',
description: 'Timer colour. Default: #f6f6f6',
type: 'colour',
defaultValue: 'f6f6f6',
},
@@ -203,7 +193,6 @@ type TimerOptions = {
freezeOvertime: boolean;
freezeMessage: string;
hidePhase: boolean;
endSound: EndSound;
font?: string;
keyColour?: string;
timerColour?: string;
@@ -219,7 +208,6 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
// Get timerType from either source
const timerType = validateTimerType(getValue('timerType'), TimerType.None);
const endSoundValue = getValue('endSound');
return {
hideClock: isStringBoolean(getValue('hideClock')),
@@ -239,7 +227,6 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
freezeMessage: getValue('freezeMessage') ?? '',
hidePhase: isStringBoolean(getValue('hidePhase')),
endSound: isEndSound(endSoundValue) ? endSoundValue : 'none',
font: getValue('font') ?? undefined,
keyColour: makeColourString(getValue('keyColour')),
@@ -1,20 +0,0 @@
import arpeggio from '../../assets/sounds/arpeggio.wav';
import bell from '../../assets/sounds/bell.wav';
import chime from '../../assets/sounds/chime.wav';
import type { SelectOption } from '../../common/components/select/Select';
// synthesised tones bundled with the app, no external source or licence to track
export const endSoundSources = { chime, bell, arpeggio } as const;
export type EndSound = 'none' | keyof typeof endSoundSources;
export const endSoundOptions: SelectOption[] = [
{ value: 'none', label: 'None' },
{ value: 'chime', label: 'Chime' },
{ value: 'bell', label: 'Bell' },
{ value: 'arpeggio', label: 'Arpeggio' },
];
export function isEndSound(value: string | null): value is EndSound {
return value === 'none' || value === 'chime' || value === 'bell' || value === 'arpeggio';
}
@@ -189,18 +189,3 @@ export function getCardData(
nextSecondary,
};
}
/**
* Whether the end of timer sound should play for a given phase transition
* We only sound the transition into overtime from a phase that was already counting,
* which keeps a client that connects or reloads mid-overtime silent
*/
export function shouldPlayEndSound(previousPhase: TimerPhase | null, phase: TimerPhase): boolean {
if (phase !== TimerPhase.Overtime) {
return false;
}
return (
previousPhase === TimerPhase.Default || previousPhase === TimerPhase.Warning || previousPhase === TimerPhase.Danger
);
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useLoadedRundown } from '../../common/hooks-query/useLoadedRundown';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
@@ -26,7 +26,7 @@ export function useTimerData(): ViewData<TimerData> {
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundown, status: rundownStatus } = useLoadedRundown();
const { data: rundown, status: rundownStatus } = useRundown();
const { entries } = rundown;
return {

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