Compare commits

..

1 Commits

Author SHA1 Message Date
alex-arc 96c8228a19 refactor: use branded types in runtimestate 2026-07-29 23:15:08 +02:00
277 changed files with 5658 additions and 11296 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", "name": "@getontime/cli",
"version": "4.13.0", "version": "4.11.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+2 -4
View File
@@ -7,15 +7,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101010" /> <meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" /> <meta name="ontime" content="ontime - time keeping for live events" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Ontime" />
<link rel="apple-touch-icon" href="ontime-logo.png" /> <link rel="apple-touch-icon" href="ontime-logo.png" />
<link rel="icon" type="image/png" href="ontime-logo.png" /> <link rel="icon" type="image/png" href="ontime-logo.png" />
<link rel="manifest" href="site.webmanifest" />
<link rel="manifest" href="manifest.json" /> <link rel="manifest" href="manifest.json" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<title>Ontime</title> <title>ontime</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
+8 -9
View File
@@ -1,10 +1,10 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.13.0", "version": "4.11.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui/react": "1.7.0", "@base-ui/react": "1.6.0",
"@codemirror/commands": "^6.0.0", "@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0", "@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -13,12 +13,12 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^9.5.1", "@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.59.0", "@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^9.1.2", "@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-vscode": "^4.25.10", "@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.18.0", "axios": "^1.18.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.80.0",
"react-icons": "5.7.0", "react-icons": "5.6.0",
"react-router": "^8.0.1", "react-router": "^8.0.1",
"react-virtuoso": "^4.18.7", "react-virtuoso": "^4.18.7",
"zustand": "^5.0.14" "zustand": "^5.0.14"
@@ -60,8 +60,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "5.4.0", "@sentry/vite-plugin": "5.1.1",
"@types/node": "catalog:",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
@@ -73,8 +72,8 @@
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "8.2.1", "vite": "8.0.1",
"vite-plugin-compression2": "2.5.3", "vite-plugin-compression2": "2.5.1",
"vite-plugin-svgr": "4.5.0", "vite-plugin-svgr": "4.5.0",
"vitest": "catalog:" "vitest": "catalog:"
} }
+7 -9
View File
@@ -1,21 +1,19 @@
{ {
"name": "Ontime", "name": "ontime",
"short_name": "Ontime", "short_name": "ontime",
"icons": [ "icons": [
{ {
"src": "ontime-logo-192.png", "src": "favicon.ico",
"sizes": "192x192", "type": "image/x-icon"
"type": "image/png"
}, },
{ {
"src": "ontime-logo-512.png", "src": "ontime-logo.png",
"sizes": "512x512",
"type": "image/png" "type": "image/png"
} }
], ],
"scope": "./", "scope": "./",
"start_url": "./", "start_url": "./",
"display": "standalone", "display": "",
"theme_color": "#101010", "theme_color": "#121212",
"background_color": "#101010" "background_color": "#101010"
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

+8
View File
@@ -0,0 +1,8 @@
{
"name": "",
"short_name": "",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
"theme_color": "#121212",
"background_color": "#101010",
"display": "standalone"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8 -3
View File
@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import type { ReportData } from 'ontime-types'; import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants'; import { REPORT, apiEntryUrl } from './constants';
@@ -8,13 +8,18 @@ import type { RequestOptions } from './requestOptions';
export const reportUrl = `${apiEntryUrl}/report`; export const reportUrl = `${apiEntryUrl}/report`;
/** /**
* HTTP request to fetch the report * HTTP request to fetch all reports
*/ */
export async function fetchReport(options?: RequestOptions): Promise<ReportData> { export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
const res = await axios.get(reportUrl, { signal: options?.signal }); const res = await axios.get(reportUrl, { signal: options?.signal });
return res.data; return res.data;
} }
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() { export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`); await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
-7
View File
@@ -176,13 +176,6 @@ export async function postCloneEntry(
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options); return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
} }
/**
* HTTP request events duration to fit inside the group target
*/
export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
}
/** /**
* HTTP request for grouping a list of entries into a group * HTTP request for grouping a list of entries into a group
*/ */
@@ -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,82 +1,24 @@
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
background-color: $gray-1300;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
}
.sectionHeader {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.separator {
display: flex;
align-items: center;
gap: 0.75rem;
color: $gray-500;
font-size: $inner-section-text-size;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
&::before,
&::after {
content: '';
flex: 1;
border-top: 1px solid $gray-1100;
}
}
.textEntry { .textEntry {
grid-area: input;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex: 1;
min-width: 0;
color: $label-gray; color: $label-gray;
} }
.inlineEntry { .inlineEntry {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-areas:
align-items: center; 'label label'
gap: 0.75rem; 'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
} }
.redirect { .redirect {
margin-left: auto; grid-area: btn;
} }
.label { .label {
color: $gray-200; color: $label-gray;
font-size: $text-body-size;
font-weight: 600;
}
.description {
color: $gray-500;
font-size: $inner-section-text-size;
}
.origin {
flex: 0 0 auto;
color: $gray-500;
font-size: $inner-section-text-size;
white-space: nowrap;
}
@media (max-width: $min-tablet) {
.inlineEntry {
grid-template-columns: 1fr;
align-items: stretch;
}
.redirect {
align-self: flex-end;
}
} }
@@ -1,7 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5'; import { IoArrowForward } from 'react-icons/io5';
import { baseURI } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig'; import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets'; import useUrlPresets from '../../hooks-query/useUrlPresets';
import { setClientRemote } from '../../hooks/useSocket'; import { setClientRemote } from '../../hooks/useSocket';
@@ -9,7 +8,7 @@ import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog'; import Dialog from '../dialog/Dialog';
import Info from '../info/Info'; import Info from '../info/Info';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import ExternalLink from '../link/external-link/ExternalLink'; import AppLink from '../link/app-link/AppLink';
import Select from '../select/Select'; import Select from '../select/Select';
import style from './RedirectClientModal.module.scss'; import style from './RedirectClientModal.module.scss';
@@ -45,7 +44,6 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
}; };
const enabledPresets = data.filter((preset) => preset.enabled); const enabledPresets = data.filter((preset) => preset.enabled);
const settingsUrl = `${window.location.origin}${baseURI}/?settings=sharing__presets`;
const viewOptions = [ const viewOptions = [
...navigatorConstants.map((view) => ({ ...navigatorConstants.map((view) => ({
@@ -68,46 +66,34 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
bodyElements={ bodyElements={
<> <>
<Info> <Info>
<Info.Body> Remotely redirect the client to a different URL. <br />
Remotely redirect the client to a different URL. Choose a custom path or select a URL Preset. Either by entering a custom path or selecting a URL Preset.
</Info.Body> <br />
<Info.Footer> <br />
<ExternalLink href={settingsUrl}>Manage URL Presets</ExternalLink> <AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
</Info.Footer>
</Info> </Info>
<section className={style.section}> <div className={style.inlineEntry}>
<div className={style.sectionHeader}> <span className={style.label}>Enter custom path</span>
<span className={style.label}>Enter custom path</span> <label className={style.textEntry}>
<span className={style.description}>Type the path the client should open.</span> {origin}
</div> <Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
<div className={style.inlineEntry}> </label>
<label className={style.textEntry}> <Button
<span className={style.origin}>{origin}</span> variant='primary'
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} /> aria-label='Redirect'
</label> disabled={path === currentPath || path === ''}
<Button className={style.redirect}
variant='primary' onClick={() => handleRedirect(path)}
aria-label='Redirect to custom path' >
disabled={path === currentPath || path === ''} Redirect
className={style.redirect} <IoArrowForward />
onClick={() => handleRedirect(path)} </Button>
>
Redirect
<IoArrowForward />
</Button>
</div>
</section>
<div className={style.separator} role='separator'>
<span>Or</span>
</div> </div>
<section className={style.section}> <div>
<div className={style.sectionHeader}> <span className={style.label}>Select View or URL Preset</span>
<span className={style.label}>Select view or URL Preset</span>
<span className={style.description}>Choose from the available destinations.</span>
</div>
<div className={style.inlineEntry}> <div className={style.inlineEntry}>
<label className={style.textEntry}> <label className={style.textEntry}>
<span className={style.origin}>{origin}</span> {origin}
<Select <Select
fluid fluid
options={viewOptions} options={viewOptions}
@@ -129,7 +115,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
Redirect <IoArrowForward /> Redirect <IoArrowForward />
</Button> </Button>
</div> </div>
</section> </div>
</> </>
} }
/> />
@@ -1,13 +0,0 @@
@use '../../../theme/ontimeStyles' as *;
.form {
padding: 0.25rem 0.5rem 0.5rem;
}
.label {
display: flex;
flex-direction: column;
gap: 0.5rem;
color: $label-gray;
font-size: $inner-section-text-size;
}
@@ -5,8 +5,6 @@ import { setClientRemote } from '../../hooks/useSocket';
import Dialog from '../dialog/Dialog'; import Dialog from '../dialog/Dialog';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps { interface RenameClientModalProps {
id: string; id: string;
name?: string; name?: string;
@@ -33,22 +31,9 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
isOpen={isOpen} isOpen={isOpen}
title={`Rename Client: ${currentName}`} title={`Rename Client: ${currentName}`}
showCloseButton showCloseButton
showBackdrop
onClose={onClose} onClose={onClose}
bodyElements={ bodyElements={
<div className={style.form}> <Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
<label className={style.label}>
Client name
<Input
height='large'
fluid
autoComplete='off'
placeholder='New name'
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
</div>
} }
footerElements={ footerElements={
<> <>
@@ -1,16 +1,12 @@
.dialog { .dialog {
position: fixed; position: fixed;
top: 10vh; top: 10%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
box-sizing: border-box;
width: min(600px, 90vw);
max-height: calc(90vh - 1rem);
display: flex;
flex-direction: column;
padding-inline: 1rem; padding-inline: 1rem;
min-width: min(600px, 90vw);
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -47,8 +43,7 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
flex: 1; max-height: min(80vh, 600px);
min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
@@ -58,5 +53,4 @@
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
@@ -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>;
}
@@ -21,15 +21,4 @@
outline: none; outline: none;
box-shadow: 0 1px 0 0 currentColor; box-shadow: 0 1px 0 0 currentColor;
} }
&:hover .icon,
&:focus-visible .icon {
transform: translateX(0.25rem);
}
}
.icon {
flex: 0 0 1.5em;
font-size: 1em;
transition: transform $transition-time-action;
} }
@@ -1,5 +1,5 @@
import { MouseEvent, ReactNode } from 'react'; import { MouseEvent, ReactNode } from 'react';
import { HiArrowLongRight } from 'react-icons/hi2'; import { IoOpenOutline } from 'react-icons/io5';
import { openLink } from '../../../utils/linkUtils'; import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
@@ -10,10 +10,9 @@ interface ExternalLinkProps {
href: string; href: string;
children: ReactNode; children: ReactNode;
inline?: boolean; inline?: boolean;
className?: string;
} }
export default function ExternalLink({ href, inline, className, children }: ExternalLinkProps) { export default function ExternalLink({ href, inline, children }: ExternalLinkProps) {
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
openLink(href); openLink(href);
@@ -24,11 +23,10 @@ export default function ExternalLink({ href, inline, className, children }: Exte
href='#!' href='#!'
target='_blank' target='_blank'
rel='noreferrer' rel='noreferrer'
className={cx([style.link, inline && style.inline, className])} className={cx([style.link, inline && style.inline])}
onClick={handleClick} onClick={handleClick}
> >
{children} {children} <IoOpenOutline style={{ fontSize: '1em' }} />
<HiArrowLongRight className={style.icon} />
</a> </a>
); );
} }
@@ -4,13 +4,10 @@
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
box-sizing: border-box; padding-inline: 1rem;
width: min(880px, 90vw); min-width: min(880px, 90vw);
min-height: min(200px, 10vh); min-height: min(200px, 10vh);
max-height: calc(90vh - 1rem); max-width: min(1200px, 90vw);
display: flex;
flex-direction: column;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
@@ -20,21 +17,14 @@
outline: none; outline: none;
} }
.compact {
min-width: 0;
width: min(680px, 90vw);
}
.small {
min-width: 0;
width: min(480px, 90vw);
}
.wide { .wide {
top: 4vh; top: 4vh;
width: min(1800px, 98vw); min-width: min(1280px, 96vw);
height: min(88vh, calc(96vh - 1rem)); max-width: min(1800px, 98vw);
min-height: 0; height: 88vh;
max-height: 88vh;
display: flex;
flex-direction: column;
} }
.backdrop { .backdrop {
@@ -51,19 +41,15 @@
.title { .title {
padding-block: 1rem; padding-block: 1rem;
padding-inline: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
} }
.titleText { .titleText {
min-width: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
overflow-wrap: anywhere;
} }
.body { .body {
@@ -71,23 +57,21 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
flex: 1; max-height: 60vh;
min-height: 0;
padding: 0.5rem 1rem;
overflow-y: auto; overflow-y: auto;
scroll-padding-block: 0.5rem;
} }
.wide .body { .wide .body {
flex: 1;
min-height: 0;
max-height: none;
overflow: hidden; overflow: hidden;
} }
.footer { .footer {
padding-block: 1rem; padding-block: 1rem;
padding-inline: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
@@ -12,7 +12,7 @@ interface ModalProps {
title?: string; title?: string;
showCloseButton?: boolean; showCloseButton?: boolean;
showBackdrop?: boolean; showBackdrop?: boolean;
size?: 'small' | 'compact' | 'default' | 'wide'; size?: 'default' | 'wide';
bodyElements: ReactNode; bodyElements: ReactNode;
footerElements?: ReactNode; footerElements?: ReactNode;
onClose: () => void; onClose: () => void;
@@ -38,7 +38,7 @@ export default function Modal({
> >
<BaseDialog.Portal> <BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />} {showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
<BaseDialog.Popup aria-label={title} className={cx([style.modal, style[size]])}> <BaseDialog.Popup aria-label={title} className={cx([style.modal, size === 'wide' && style.wide])}>
<div className={style.title}> <div className={style.title}>
{title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />} {title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />}
{showCloseButton && ( {showCloseButton && (
@@ -9,7 +9,6 @@ import './MultiPartProgressBar.scss';
interface MultiPartProgressBar { interface MultiPartProgressBar {
now: MaybeNumber; now: MaybeNumber;
complete: MaybeNumber; complete: MaybeNumber;
eventId?: string | null;
normalColor: string; normalColor: string;
warning?: MaybeNumber; warning?: MaybeNumber;
warningColor: string; warningColor: string;
@@ -25,7 +24,6 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { const {
now, now,
complete, complete,
eventId,
normalColor, normalColor,
warning, warning,
warningColor, warningColor,
@@ -37,7 +35,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '', className = '',
} = props; } = props;
const percentRemaining = 100 - useAnimatedProgress(now, complete, eventId); const percentRemaining = 100 - useAnimatedProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0; const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0; const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0; const isOvertime = now !== null && now < 0;
@@ -1,3 +1,7 @@
.separator {
border-color: $border-color-ondark;
}
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -19,15 +23,14 @@
width: 22rem; width: 22rem;
height: 100vh; height: 100vh;
height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
border-right: 1px solid $gray-1100; border-right: 1px solid $gray-1100;
box-shadow: $box-shadow-right;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -47,51 +50,22 @@
} }
.header { .header {
flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; height: 3.5rem;
min-height: 4rem; padding-inline: 1.5rem;
padding: 0.75rem 1rem 0.75rem 1.25rem;
border-bottom: 1px solid $white-10;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600; font-weight: 600;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.2;
}
.clientName {
font-size: $aux-text-size;
color: $gray-600;
@include ellipsis-text;
} }
.body { .body {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding-block: 0.5rem 1rem;
} }
.group { .note {
display: flex; margin-left: auto;
flex-direction: column; color: $white-20;
&:not(:first-child) {
margin-top: 0.75rem;
}
}
.groupLabel {
padding: 0.5rem 1.25rem 0.25rem;
color: $gray-700;
} }
@@ -1,7 +1,7 @@
import { Dialog } from '@base-ui/react/dialog'; import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks'; import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { memo, PropsWithChildren } from 'react'; import { memo } from 'react';
import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5'; import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu'; import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
@@ -14,11 +14,9 @@ import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
import { RenameClientModal } from '../client-modal/RenameClientModal'; import { RenameClientModal } from '../client-modal/RenameClientModal';
import Eyebrow from '../eyebrow/Eyebrow';
import ClientLink from './client-link/ClientLink'; import ClientLink from './client-link/ClientLink';
import EditorNavigation from './editor-navigation/EditorNavigation'; import EditorNavigation from './editor-navigation/EditorNavigation';
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
import OtherAddresses from './other-addresses/OtherAddresses'; import OtherAddresses from './other-addresses/OtherAddresses';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -35,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false); const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreenDocument(); const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore(); const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions(); const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation(); const location = useLocation();
@@ -54,93 +52,75 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} /> <RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
<Dialog.Popup className={style.drawer}> <Dialog.Popup className={style.drawer}>
<div className={style.header}> <div className={style.header}>
<div className={style.headerText}> <Dialog.Title>Ontime</Dialog.Title>
<Dialog.Title className={style.title}>Ontime</Dialog.Title> <IconButton variant='subtle-white' size='large' onClick={onClose}>
{name && <span className={style.clientName}>{name}</span>}
</div>
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
<IoClose /> <IoClose />
</IconButton> </IconButton>
</div> </div>
<div className={style.body}> <div className={style.body}>
<MenuGroup label='This screen'> {supportsFullscreen && (
{supportsFullscreen && ( <NavigationMenuItem active={fullscreen} onClick={toggle}>
<NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} /> Toggle Fullscreen
)} {fullscreen ? <IoContract /> : <IoExpand />}
<NavigationMenuToggle
checked={mirror}
icon={<IoSwapVertical />}
label='Flip Screen'
onToggle={() => toggleMirror()}
/>
{canUseWakeLock && (
<NavigationMenuToggle
checked={keepAwake}
icon={<LuCoffee />}
label='Keep Awake'
onToggle={toggleKeepAwake}
/>
)}
<NavigationMenuItem onClick={handlers.open}>
<IoPencilOutline />
Rename Client
</NavigationMenuItem> </NavigationMenuItem>
</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'> <hr className={style.separator} />
<EditorNavigation />
<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 <ClientLink
to='cuesheet' key={route.url}
current={location.pathname === '/cuesheet'} to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined} postAction={isSmallScreen ? onClose : undefined}
> >
<IoLockClosedOutline /> {route.label}
Cuesheet
</ClientLink> </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} /> <PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div> </div>
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />} {isLocalhost && (
<div>
<OtherAddresses currentLocation={location.pathname} />
</div>
)}
</Dialog.Popup> </Dialog.Popup>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </Dialog.Root>
); );
} }
function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.group}>
<div className={style.groupLabel}>
<Eyebrow>{label}</Eyebrow>
</div>
{children}
</div>
);
}
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) { function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation(); const location = useLocation();
const { data: urlPresets } = useUrlPresets(); const { data: urlPresets } = useUrlPresets();
@@ -149,7 +129,8 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
if (navPresets.length === 0) return null; if (navPresets.length === 0) return null;
return ( return (
<MenuGroup label='Presets'> <>
<hr className={style.separator} />
{navPresets.map((preset) => ( {navPresets.map((preset) => (
<ClientLink <ClientLink
key={preset.alias} key={preset.alias}
@@ -160,6 +141,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
{preset.alias} {preset.alias}
</ClientLink> </ClientLink>
))} ))}
</MenuGroup> </>
); );
} }
@@ -1,8 +1,8 @@
import { useHotkeys } from '@mantine/hooks'; import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo, useCallback, useState } from 'react'; import { memo } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { hasCustomParams } from '../../stores/savedViewParams'; import { hasCustomParams, useSavedViewParams } from '../../stores/savedViewParams';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store'; import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation'; import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu'; import NavigationMenu from './NavigationMenu';
@@ -15,37 +15,22 @@ interface ViewNavigationMenuProps {
suppressSettings?: boolean; suppressSettings?: boolean;
} }
function isInteractiveKeyboardAction(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target.closest(
'button, a, input, textarea, select, [role="button"], [role="checkbox"], [role="switch"], [contenteditable]:not([contenteditable="false"])',
) !== null
);
}
export default memo(ViewNavigationMenu); export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) { function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore(); const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const hasSavedChanges = hasCustomParams(searchParams); const savedParams = useSavedViewParams((store) => store.params);
const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0;
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
useHotkeys([ useHotkeys([
[ [
'Space', 'Space',
(event) => { () => {
if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return; if (isNavigationLocked) return;
event.preventDefault(); menuHandler.toggle();
toggleMenu();
}, },
{ preventDefault: false }, { preventDefault: true },
], ],
[ [
'mod + ,', 'mod + ,',
@@ -64,11 +49,11 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
return ( return (
<> <>
<FloatingNavigation <FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : toggleMenu} toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer} toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges} hasSavedChanges={hasSavedChanges}
/> />
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />} {!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
</> </>
); );
} }
@@ -2,68 +2,35 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.625rem 1.25rem; padding: 0.75rem 1.5rem;
gap: 0.625rem; gap: 0.5rem;
width: 100%; width: 100%;
border-left: 3px solid transparent; border-left: 4px solid transparent;
color: $gray-200; color: $action-text-color;
font-size: $text-body-size;
white-space: nowrap; white-space: nowrap;
text-align: left;
cursor: pointer; cursor: pointer;
transition-property: color, background-color; transition-property: color;
transition-duration: $transition-time-action; transition-duration: $transition-time-action;
// keep icons on a fixed box so labels align regardless of the glyph
> svg {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
color: $gray-500;
transition: color $transition-time-action;
}
&:hover { &:hover {
background-color: $white-3; color: $ontime-color;
color: $ui-white; background-color: $gray-1350;
> svg {
color: $gray-300;
}
} }
&:active { &:active {
background-color: $white-7; background-color: $border-color-ondark;
} }
&:focus-visible { &:focus {
outline: 2px solid $blue-500; outline: 2px solid $blue-500;
outline-offset: -2px; background-color: $gray-1350;
background-color: $white-3;
}
// toggle rows are labels, the focusable control sits inside them
&:focus-within {
background-color: $white-3;
} }
&.current { &.current {
background-color: $bg-active; background-color: $gray-1300;
border-left-color: $blue-400; border-left: 4px solid $action-text-color;
color: $ui-white;
font-weight: 600;
> svg {
color: $blue-300;
}
} }
} }
.label {
flex: 1;
min-width: 0;
@include ellipsis-text;
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent'; import { isKeyEnter } from '../../../utils/keyEvent';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
import style from './NavigationMenuItem.module.scss'; import style from './NavigationMenuItem.module.scss';
@@ -11,7 +11,6 @@ interface NavigationMenuItemProps {
onClick: () => void; onClick: () => void;
} }
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
export default function NavigationMenuItem({ export default function NavigationMenuItem({
active, active,
className, className,
@@ -25,9 +24,7 @@ export default function NavigationMenuItem({
role='button' role='button'
onClick={onClick} onClick={onClick}
onKeyDown={(event) => { onKeyDown={(event) => {
if (isKeyEnter(event) || isKeySpace(event)) { if (isKeyEnter(event)) {
event.preventDefault();
event.stopPropagation();
onClick(); onClick();
} }
}} }}
@@ -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 { .header {
padding: 0 1.25rem 0.25rem; font-size: calc(1rem - 2px);
margin-left: 1rem;
color: $gray-700; color: $gray-700;
} }
.interfaces { .interfaces {
padding: 0.25rem 1.25rem 0; padding: 0.5rem 0.5rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.25rem; gap: 0.5rem;
}
.interface {
min-width: 0;
max-width: 100%;
> div {
max-width: 100%;
> button:first-child {
min-width: 0;
max-width: calc(100% - 1.5rem);
justify-content: flex-start;
padding-inline: 0.5rem;
}
}
} }
.interfaceCopy { .interfaceCopy {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0;
width: 100%;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.goIcon { .goIcon {
flex: 0 0 auto;
@include rotate-fourty-five; @include rotate-fourty-five;
margin-left: 0.25rem; margin-left: 0.25rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
@@ -3,8 +3,6 @@ import { IoArrowUp } from 'react-icons/io5';
import useInfo from '../../../hooks-query/useInfo'; import useInfo from '../../../hooks-query/useInfo';
import { linkToOtherHost, openLink } from '../../../utils/linkUtils'; import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
import CopyTag from '../../copy-tag/CopyTag'; import CopyTag from '../../copy-tag/CopyTag';
import Eyebrow from '../../eyebrow/Eyebrow';
import { getExternalInterfaces } from './otherAddresses.utils';
import style from './OtherAddresses.module.scss'; import style from './OtherAddresses.module.scss';
@@ -14,33 +12,32 @@ interface OtherAddressesProps {
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) { export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
const { data } = useInfo(); const { data } = useInfo();
const externalInterfaces = getExternalInterfaces(data.networkInterfaces);
if (externalInterfaces.length === 0) { // there is no point showing this if we only have one interface
if (data.networkInterfaces.length < 2) {
return null; return null;
} }
return ( return (
<div className={style.footer}> <>
<div className={style.header}> <div className={style.header}>Accessible on external networks</div>
<Eyebrow>Also available at</Eyebrow>
</div>
<div className={style.interfaces}> <div className={style.interfaces}>
{externalInterfaces.map((nif) => { {data?.networkInterfaces?.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation); const address = linkToOtherHost(nif.address, currentLocation);
return ( return (
<div key={nif.name} className={style.interface}> <CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
<CopyTag copyValue={address} onClick={() => openLink(address)} size='small'> <span className={style.interfaceCopy}>
<span className={style.interfaceCopy}> {nif.address} <IoArrowUp className={style.goIcon} />
<span className={style.address}>{nif.address}</span> </span>
<IoArrowUp className={style.goIcon} /> </CopyTag>
</span>
</CopyTag>
</div>
); );
})} })}
</div> </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');
}
@@ -7,12 +7,12 @@ import './ProgressBar.scss';
interface ProgressBarProps { interface ProgressBarProps {
current: MaybeNumber; current: MaybeNumber;
duration: MaybeNumber; duration: MaybeNumber;
eventId?: string | null;
className?: string; className?: string;
} }
export default function ProgressBar({ current, duration, eventId, className }: ProgressBarProps) { export default function ProgressBar(props: ProgressBarProps) {
const progress = useAnimatedProgress(current, duration, eventId); const { current, duration, className } = props;
const progress = useAnimatedProgress(current, duration);
return ( return (
<div className={`progress-bar__bg ${className}`}> <div className={`progress-bar__bg ${className}`}>
@@ -1,43 +1,20 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer { .emptyContainer {
width: 100%; width: 100%;
text-align: center; text-align: center;
color: var(--secondary-color-override, $viewer-secondary-color); color: $white-10;
.empty { .empty {
display: block; display: block;
width: min(100%, 14rem); width: min(100%, 24rem);
margin: 0 auto -1.5rem; margin-inline: auto;
opacity: 0.6; opacity: 0.8;
} }
.text { .text {
display: block; display: block;
margin-inline: auto; margin-inline: auto;
font-weight: 400; font-weight: 600;
font-size: clamp(1rem, 1.55vw, 1.5rem); font-size: 2em;
line-height: 1.35; max-width: min(100%, 600px);
max-width: min(100%, 40rem);
}
&.error {
color: $error-red;
.empty {
opacity: 0.35;
filter: grayscale(1);
}
.text {
margin-top: 0.5rem;
}
}
.errorIcon {
display: block;
width: 1.5rem;
height: 1.5rem;
margin: -0.125rem auto 0;
} }
} }
@@ -1,5 +1,4 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react'; import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
@@ -10,18 +9,12 @@ interface EmptyProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
className?: string; className?: string;
variant?: 'error';
} }
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) { export default function Empty({ text, className, injectedStyles }: EmptyProps) {
return ( return (
<div <div className={cx([style.emptyContainer, className])} style={injectedStyles}>
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<EmptyImage className={style.empty} /> <EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>} {text && <span className={style.text}>{text}</span>}
</div> </div>
); );
@@ -1,11 +0,0 @@
.fill {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
display: grid;
place-items: start center;
padding: clamp(4rem, 20dvh, 12rem) 1.5rem 1rem;
}
@@ -1,19 +0,0 @@
import { cx } from '../../utils/styleUtils';
import Empty from './Empty';
import style from './EmptyFill.module.scss';
interface EmptyFillProps {
text?: string;
/** placed on the fill wrapper — e.g. to assign a grid-area in a grid parent */
className?: string;
}
/** Container-filling empty/loading state for panels and grid/flex cells. */
export default function EmptyFill({ text, className }: EmptyFillProps) {
return (
<div className={cx([style.fill, className])}>
<Empty text={text} />
</div>
);
}
@@ -6,7 +6,7 @@
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%; /* restrict the page width to viewport */
height: 100dvh; height: 100vh;
font-family: var(--font-family-override, $viewer-font-family); font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color); background: var(--background-color-override, $viewer-background-color);
@@ -16,6 +16,5 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; padding-top: 5rem;
padding-block: 5rem;
} }
@@ -7,13 +7,12 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps { interface EmptyPageProps {
text?: string; text?: string;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
variant?: 'error';
} }
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) { export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
return ( return (
<div className={style.page}> <div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} variant={variant} /> <Empty text={text} injectedStyles={injectedStyles} />
</div> </div>
); );
} }
@@ -15,4 +15,9 @@
gap: 1rem; gap: 1rem;
margin-top: 1em; margin-top: 1em;
} }
.text {
font-weight: 600;
font-size: 2em;
}
} }
@@ -18,7 +18,8 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}> <tbody className={style.emptyContainer}>
<tr> <tr>
<td colSpan={99} className={style.emptyCell}> <td colSpan={99} className={style.emptyCell}>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} /> <Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && ( {handleAddNew && (
<div className={style.inline}> <div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'> <Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -11,11 +11,6 @@
color: $ui-white; color: $ui-white;
} }
.active {
border: 1px solid $green-700;
color: $green-400;
}
.warning { .warning {
background-color: $orange-1300; background-color: $orange-1300;
color: $orange-300; color: $orange-300;
@@ -6,7 +6,7 @@ import style from './Tag.module.scss';
interface TagProps { interface TagProps {
className?: string; className?: string;
variant?: 'default' | 'active' | 'warning'; variant?: 'default' | 'warning';
} }
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) { export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
@@ -2,12 +2,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
> span {
font-variant-numeric: tabular-nums;
text-align: right;
text-transform: uppercase;
}
} }
// attempt to match with ontimeTextInputs // attempt to match with ontimeTextInputs
@@ -1,77 +1,37 @@
.multiOptionList { .inline {
overflow-y: auto; display: inline-flex;
max-height: 12rem; align-items: center;
background: $gray-1200; flex-wrap: wrap;
border: 1px solid $gray-1100; gap: 1rem;
border-radius: $component-border-radius-md;
} }
// styles from subtle button
.toggleSelect { .toggleSelect {
box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.25rem;
min-height: 2.5rem; padding-inline: 0.5rem;
padding: 0.5rem 0.75rem; height: 2.5rem;
background: transparent; background: $gray-1050;
border: 0; color: $ui-white;
color: $gray-300;
line-height: 1em; line-height: 1em;
cursor: pointer; border-radius: $component-border-radius-md;
transition:
background-color $transition-time-action,
border-color $transition-time-action,
color $transition-time-action;
&:hover { &:hover:not(:disabled):not(:active) {
background: $gray-1000; background: $gray-1000;
color: $ui-white; color: $blue-500;
} }
&:active { &:active:not(:disabled) {
background: $gray-1100; background: $gray-1100;
} border-color: $gray-1250;
&:focus-within {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
&.selected {
background: $bg-active;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
}
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
> [role='checkbox'] {
flex: 0 0 auto;
width: 1.125rem;
height: 1.125rem;
border: 1px solid $gray-400;
&[data-unchecked] {
background: $gray-1100;
}
&[data-checked] {
border-color: $blue-400;
}
&:focus-visible {
outline: 0;
}
} }
&::after { &::after {
content: ''; content: '';
flex: 0 0 auto; margin-left: 0.25rem;
margin-left: auto; width: 0.75em;
width: 0.625rem; height: 0.75em;
height: 0.625rem;
background: var(--user-bg, $gray-900); background: var(--user-bg, $gray-900);
border-radius: 50%; border-radius: 50%;
} }
@@ -2,7 +2,6 @@ import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../views/common/viewUtils'; import { isStringBoolean } from '../../../views/common/viewUtils';
import { cx } from '../../utils/styleUtils';
import Checkbox from '../checkbox/Checkbox'; import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select'; import Select, { SelectOption } from '../select/Select';
@@ -108,20 +107,18 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
return ( return (
<> <>
<input name={id} hidden readOnly value={paramState.join(',')} /> <input name={id} hidden readOnly value={paramState.join(',')} />
<div className={style.multiOptionList}> <div className={style.inline}>
{values.map((option) => { {values.map((option) => {
const selected = paramState.includes(option.value);
return ( return (
<label <label
key={option.value} key={option.value}
className={cx([style.toggleSelect, selected && style.selected])} className={style.toggleSelect}
style={{ style={{
'--user-bg': option.colour, '--user-bg': option.colour,
}} }}
> >
<Checkbox <Checkbox
checked={selected} checked={paramState.includes(option.value)}
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)} onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
/> />
{option.label} {option.label}
@@ -1,3 +1,13 @@
.drawerFooter {
display: flex;
justify-content: end;
gap: $section-spacing;
button {
padding: 0 2em;
}
}
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -24,11 +34,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem 1.5rem;
background-color: $ui-black; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
border-left: 1px solid $gray-1100; border-left: 1px solid $gray-1100;
box-shadow: $box-shadow-l1;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -48,54 +58,36 @@
} }
.header { .header {
flex: 0 0 auto; padding-inline: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; height: 3.5rem;
min-height: 4rem;
padding: 0.75rem 1rem;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600; font-weight: 600;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.2;
}
.target {
font-size: $aux-text-size;
color: $gray-600;
} }
.body { .body {
flex: 1; flex: 1;
padding: 1rem; padding-inline: 1rem;
padding-bottom: 10vh;
overflow-y: auto; overflow-y: auto;
} }
.footer { .footer {
flex: 0 0 auto;
display: flex; display: flex;
padding: 0.75rem 1rem; padding-inline: 1rem;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
background-color: $ui-black;
border-top: 1px solid $white-10;
} }
.sectionList { .sectionList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 2rem;
padding-right: 0.5rem;
} }
.info { .info {
@@ -4,7 +4,6 @@ import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { viewLabels } from '../../../viewerConfig';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams'; import { useSavedViewParams } from '../../stores/savedViewParams';
@@ -72,17 +71,8 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
<Dialog.Backdrop className={style.backdrop} /> <Dialog.Backdrop className={style.backdrop} />
<Dialog.Popup className={style.drawer}> <Dialog.Popup className={style.drawer}>
<div className={style.header}> <div className={style.header}>
<div className={style.headerText}> <Dialog.Title>Customise</Dialog.Title>
<Dialog.Title className={style.title}>Customise</Dialog.Title> <IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
<span className={style.target}>{viewLabels[target]}</span>
</div>
<IconButton
variant='subtle-white'
size='large'
aria-label='Close'
data-testid='close-view-params'
onClick={handleClose}
>
<IoClose /> <IoClose />
</IconButton> </IconButton>
</div> </div>
@@ -1,21 +1,10 @@
.presetSection { .presetSection {
background-color: $gray-1350; background-color: $gray-1350;
border: 1px solid $white-10;
border-radius: $component-border-radius-md;
margin-bottom: 0.75rem;
}
.header {
padding: 0.5rem 1rem;
min-height: 2.25rem;
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 0.25rem;
color: $gray-300; padding: 1rem 0.5rem;
} margin-bottom: 1rem;
.presetList {
border-top: 1px solid $white-7;
max-height: 10rem; max-height: 10rem;
overflow-y: auto; overflow-y: auto;
scrollbar-gutter: stable; scrollbar-gutter: stable;
@@ -25,39 +14,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
min-height: 2.5rem;
padding: 0.375rem 1rem;
font-size: $inner-section-text-size;
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
&.active { &.active {
background-color: $bg-active; color: $blue-500;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
font-weight: 600;
} }
} }
.presetName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presetActions { .presetActions {
margin-left: auto; margin-left: auto;
} }
.current {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
color: $blue-400;
font-size: $aux-text-size;
font-weight: 600;
}
@@ -4,7 +4,6 @@ import { useSearchParams } from 'react-router';
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets'; import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import Eyebrow from '../eyebrow/Eyebrow';
import style from './ViewParamsPresets.module.scss'; import style from './ViewParamsPresets.module.scss';
@@ -27,31 +26,22 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
return ( return (
<div className={style.presetSection}> <div className={style.presetSection}>
<div className={style.header}> {viewPresets.map((preset) => {
<Eyebrow>Saved presets</Eyebrow> const active = searchParams.get('alias') === preset.alias;
</div> return (
<div className={style.presetList}> <div key={preset.alias} className={cx([style.preset, active && style.active])}>
{viewPresets.map((preset) => { <div>{preset.alias}</div>
const active = searchParams.get('alias') === preset.alias; <Button
return ( variant={active ? 'ghosted' : 'subtle-white'}
<div key={preset.alias} className={cx([style.preset, active && style.active])}> onClick={() => handleRecall(preset)}
<span className={style.presetName}>{preset.alias}</span> disabled={active}
{active ? ( className={style.presetActions}
<span className={style.current}>Current</span> >
) : ( {active ? 'Applied' : 'Apply'}
<Button </Button>
variant='ghosted' </div>
size='small' );
onClick={() => handleRecall(preset)} })}
className={style.presetActions}
>
Apply
</Button>
)}
</div>
);
})}
</div>
</div> </div>
); );
} }
@@ -1,118 +1,48 @@
.section { .section {
overflow: hidden;
color: $ui-white; color: $ui-white;
font-size: 1rem; font-size: 1rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
} }
.sectionHeader { .sectionHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.5rem; height: 2rem;
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 1rem;
background: transparent;
border: 0;
font: inherit;
text-align: left;
color: $gray-300;
&.collapsible { &.collapsible {
cursor: pointer; cursor: pointer;
transition-property: color, background-color;
transition-duration: $transition-time-action;
&:hover {
background-color: $white-3;
color: $ui-white;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
} }
} }
.sectionTitle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/** marks a section which holds values coming from the URL */
.customised {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background-color: $active-indicator;
flex-shrink: 0;
}
.chevron {
flex-shrink: 0;
rotate: 180deg;
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&.closed {
rotate: 0deg;
}
}
.options {
padding-inline: 1rem;
border-top: 1px solid $white-7;
}
.label { .label {
display: grid; margin-top: $section-spacing;
grid-template-columns: minmax(0, 1fr);
row-gap: 0.25rem;
padding: 0.75rem 0;
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray;
&:not(:last-child) { display: flex;
border-bottom: 1px solid $white-7; flex-direction: column;
} gap: 0.25rem;
}
/**
* Compact controls sit beside their label instead of below it.
* Placement is done with grid areas so that the markup stays flat.
*/
.inline {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
column-gap: 1rem;
> .title {
grid-area: 1 / 1;
}
> .description {
grid-area: 2 / 1;
}
> *:last-child {
grid-area: 1 / 2 / span 2;
}
} }
.title { .title {
color: $ui-white; font-size: $inner-section-text-size;
display: block;
width: 100%;
} }
.description { .description {
color: $gray-400; font-size: $inner-section-text-size;
font-size: $aux-text-size; display: block;
line-height: 1.4; color: $modal-note-color;
}
.closed {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 0deg;
}
.open {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 180deg;
} }
.hidden { .hidden {
@@ -1,9 +1,7 @@
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from 'react-icons/io5'; import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Eyebrow from '../eyebrow/Eyebrow';
import { OptionTitle } from './constants'; import { OptionTitle } from './constants';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { type ParamField } from './viewParams.types'; import { type ParamField } from './viewParams.types';
@@ -16,74 +14,50 @@ interface ViewParamsSectionProps {
options: ParamField[]; options: ParamField[];
} }
/** Fields which are compact enough to share a row with their label */
function isInlineField(option: ParamField): boolean {
return option.type === 'boolean' || option.type === 'colour';
}
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) { export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false }); const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const [searchParams] = useSearchParams();
// hidden options are inputs without a UI, there is nothing to frame them with const handleCollapse = () => {
if (title === OptionTitle.Hidden) { if (collapsible) {
return <HiddenContents options={options} />; setCollapsed((prev) => !prev);
} }
};
const isCollapsed = Boolean(collapsible && collapsed);
return ( return (
<section className={style.section}> <section className={style.section}>
<SectionHeader {title === OptionTitle.Hidden ? (
title={title} <HiddenContents options={options} />
isCustomised={options.some((option) => searchParams.has(option.id))} ) : (
collapsed={isCollapsed} <>
onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined} <div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
/> {title}
{/* collapsed options stay mounted: the form reads its values from the DOM */} {collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
<div className={cx([style.options, isCollapsed && style.hidden])}> </div>
{options.map((option) => ( <SectionContents options={options} collapsed={collapsed} />
<label key={option.title} className={cx([style.label, isInlineField(option) && style.inline])}> </>
<span className={style.title}>{option.title}</span> )}
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
))}
</div>
</section> </section>
); );
} }
interface SectionHeaderProps { interface SectionContentsProps {
title: string; options: ParamField[];
isCustomised: boolean;
collapsed: boolean; collapsed: boolean;
/** a header without a toggle belongs to a section which cannot collapse */
onToggle?: () => void;
} }
function SectionHeader({ title, isCustomised, collapsed, onToggle }: SectionHeaderProps) { function SectionContents({ options, collapsed }: SectionContentsProps) {
const label = (
<Eyebrow className={style.sectionTitle}>
{title}
{isCustomised && <span className={style.customised} title='Contains custom values' />}
</Eyebrow>
);
if (!onToggle) {
return <div className={style.sectionHeader}>{label}</div>;
}
return ( return (
<button <>
type='button' {options.map((option) => {
className={cx([style.sectionHeader, style.collapsible])} return (
aria-expanded={!collapsed} <label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
onClick={onToggle} <span className={style.title}>{option.title}</span>
> <span className={style.description}>{option.description}</span>
{label} <ParamInput paramField={option} />
<IoChevronDown className={cx([style.chevron, collapsed && style.closed])} /> </label>
</button> );
})}
</>
); );
} }
@@ -1,34 +1,17 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { ReportData } from 'ontime-types'; import { OntimeReport } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils'; import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT } from '../api/constants'; import { REPORT } from '../api/constants';
import { fetchReport } from '../api/report'; import { fetchReport } from '../api/report';
const emptyReport: ReportData = {
eventReports: {},
rundown: null,
show: {
plannedStart: null,
plannedEnd: null,
plannedDuration: null,
actualStart: null,
actualEnd: null,
actualDuration: null,
},
};
export default function useReport() { export default function useReport() {
const { data: report, refetch } = useQuery<ReportData>({ const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT, queryKey: REPORT,
queryFn: ({ signal }) => fetchReport({ signal }), queryFn: ({ signal }) => fetchReport({ signal }),
placeholderData: (previousData) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR, staleTime: MILLIS_PER_HOUR,
}); });
return { return { data: data ?? {}, refetch };
data: report?.eventReports ?? emptyReport.eventReports,
report: report ?? emptyReport,
refetch,
};
} }
@@ -1,34 +1,26 @@
import { EntryId, MaybeNumber, Playback } from 'ontime-types'; import { MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress'; import { getProgress } from '../utils/getProgress';
import { useIsOnline, usePlayback } from './useSocket'; import { usePlayback } from './useSocket';
/** /**
* Returns the live completion percentage (0100) of a countdown, interpolated locally. * Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/ */
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber, eventId?: EntryId | null): number { export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
const playback = usePlayback(); const playback = usePlayback();
const isOnline = useIsOnline();
const isRunning = playback === Playback.Play || playback === Playback.Roll; const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, duration, eventId, playback, at: performance.now() }); const baseline = useRef({ current, at: performance.now() });
const [, setTick] = useState(0); const [, setTick] = useState(0);
const now = performance.now();
const hasAuthoritativeUpdate = // there is only something to animate while a running timer is counting down towards 0
baseline.current.current !== current || // handle timer updates const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
baseline.current.duration !== duration || // handle duration changes
baseline.current.eventId !== eventId || // handle event changing
baseline.current.playback !== playback; // handle playback changes
if (hasAuthoritativeUpdate) { // re-anchor to the authoritative value whenever the server pushes a new timer update
// Reset during render so an event change is reflected in this very paint. useEffect(() => {
baseline.current = { current, duration, eventId, playback, at: now }; baseline.current = { current, at: performance.now() };
} }, [current, duration, playback]);
// There is only something to animate while a connected timer is counting down towards 0.
const shouldAnimate = isOnline && isRunning && current !== null && current > 0 && duration !== null;
// while counting down, re-render every animation frame so the derived progress stays smooth // while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => { useEffect(() => {
@@ -42,8 +34,8 @@ export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber,
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [shouldAnimate]); }, [shouldAnimate]);
// Derive from the anchor plus elapsed time at render; freeze while disconnected or not running. // derive from the anchor plus elapsed time at render; frozen to the anchor when not running
const anchored = baseline.current.current; const anchored = baseline.current.current;
const value = isOnline && isRunning && anchored !== null ? anchored - (now - baseline.current.at) : anchored; const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
return getProgress(value, duration); return getProgress(value, duration);
} }
+1 -24
View File
@@ -49,7 +49,6 @@ import {
requestEventSwap, requestEventSwap,
requestGroupEntries, requestGroupEntries,
requestUngroup, requestUngroup,
requestFitGroupTarget,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings'; import { useEditorSettings } from '../stores/editorSettings';
@@ -467,27 +466,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
return previousEnd; return previousEnd;
} }
}, },
[getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey], [getCurrentRundownData, updateEntryMutation, queryClient],
);
/**
* Updates time of existing event so it satisfies the group target duration
* @param eventId {EntryId} - id of the event
*/
const matchGroupDuration = useCallback(
async (eventId: EntryId) => {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
try {
await requestFitGroupTarget(rundownId, eventId);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[getCurrentRundownData],
); );
/** /**
@@ -1030,7 +1009,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
matchGroupDuration,
}), }),
[ [
addEntry, addEntry,
@@ -1048,7 +1026,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents, swapEvents,
updateEntry, updateEntry,
updateTimer, updateTimer,
matchGroupDuration,
], ],
); );
} }
@@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types';
import { RefObject, useCallback, useEffect } from 'react'; import { RefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>( function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef | null>, componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef | null>, scrollRef: RefObject<ScrollRef>,
topOffset: number, topOffset: number,
) { ) {
if (!componentRef.current || !scrollRef.current) { if (!componentRef.current || !scrollRef.current) {
@@ -21,16 +21,18 @@ interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>; followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean; doFollow: boolean;
followTrigger: MaybeString; // this would be an entry id or null topOffset?: number;
getTopOffset: () => number; setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
} }
export default function useFollowComponent({ export default function useFollowComponent({
followRef, followRef,
scrollRef, scrollRef,
doFollow, doFollow,
topOffset = 100,
setScrollFlag,
followTrigger, followTrigger,
getTopOffset,
}: UseFollowComponentProps) { }: UseFollowComponentProps) {
// when trigger moves, view should follow // when trigger moves, view should follow
useEffect(() => { useEffect(() => {
@@ -39,17 +41,25 @@ export default function useFollowComponent({
} }
if (followRef.current && scrollRef.current) { if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded // Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
// resolve the offset after layout, so that measured values are up to date scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
scrollToComponent(followRef, scrollRef, getTopOffset()); setScrollFlag?.(false);
}); });
} }
}, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]); }, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
const scrollToRefComponent = useCallback(() => { const scrollToRefComponent = useCallback(
scrollToComponent(followRef, scrollRef, getTopOffset()); (componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
}, [followRef, scrollRef, getTopOffset]); if (componentRef && containerRef) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent; return scrollToRefComponent;
} }
@@ -163,7 +163,6 @@ export const useNextFlag = createSelector((state: RuntimeStore) => ({
export const useProgressData = createSelector((state: RuntimeStore) => ({ export const useProgressData = createSelector((state: RuntimeStore) => ({
current: state.timer.current, current: state.timer.current,
duration: state.timer.duration, duration: state.timer.duration,
eventId: state.eventNow?.id ?? null,
timeWarning: state.eventNow?.timeWarning ?? null, timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
@@ -6,6 +6,6 @@ import { useEffect } from 'react';
*/ */
export function useWindowTitle(title: string) { export function useWindowTitle(title: string) {
useEffect(() => { useEffect(() => {
document.title = `Ontime - ${title}`; document.title = `ontime - ${title}`;
}, []); }, []);
} }
@@ -1,23 +0,0 @@
import type { OntimeEventReport } from 'ontime-types';
import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils';
import { getEventVariance } from '../report';
it('uses captured days when measuring an event across midnight', () => {
const report: OntimeEventReport = {
startedAt: dayInMs - 5 * MILLIS_PER_MINUTE,
startedAtDay: 0,
endedAt: 5 * MILLIS_PER_MINUTE,
endedAtDay: 1,
scheduledStart: dayInMs - 5 * MILLIS_PER_MINUTE,
scheduledDay: 0,
scheduledDuration: 10 * MILLIS_PER_MINUTE,
};
expect(getEventVariance(report)).toMatchObject({
actualDuration: 10 * MILLIS_PER_MINUTE,
delta: 0,
status: 'ontime',
});
expect(getEventVariance({ ...report, endedAt: null })).toMatchObject({ status: 'not-run' });
});
@@ -58,16 +58,4 @@ describe('formatDuration()', () => {
expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s'); expect(formatDuration(2 * MILLIS_PER_HOUR + 6 * MILLIS_PER_MINUTE + 45 * MILLIS_PER_SECOND, false)).toBe('2h6m45s');
expect(formatDuration(599702, false)).toBe('9m59s'); expect(formatDuration(599702, false)).toBe('9m59s');
}); });
it('formats durations differently with and without seconds', () => {
expect(formatDuration(0, false)).toBe('0m');
expect(formatDuration(0, true)).toBe('0m');
expect(formatDuration(30 * MILLIS_PER_SECOND, false)).toBe('30s');
expect(formatDuration(30 * MILLIS_PER_SECOND, true)).toBe('');
expect(formatDuration(2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_SECOND, false)).toBe('2h30s');
expect(formatDuration(2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_SECOND, true)).toBe('2h');
expect(formatDuration(2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 30 * MILLIS_PER_SECOND, false)).toBe(
'2h10m30s',
);
expect(formatDuration(2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 30 * MILLIS_PER_SECOND, true)).toBe('2h10m');
});
}); });
-4
View File
@@ -4,10 +4,6 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Enter'; return event.key === 'Enter';
} }
export function isKeySpace<T>(event: KeyboardEvent<T>): boolean {
return event.key === ' ' || event.code === 'Space';
}
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean { export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape'; return event.key === 'Escape';
} }
-29
View File
@@ -1,29 +0,0 @@
import type { MaybeNumber, OntimeEventReport } from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND } from 'ontime-utils';
type EventVariance = {
actualDuration: MaybeNumber;
delta: number;
status: 'ontime' | 'over' | 'under' | 'not-run';
};
const notRun: EventVariance = { actualDuration: null, delta: 0, status: 'not-run' };
export function getReportTimePosition(time: number, day: number): number;
export function getReportTimePosition(time: MaybeNumber, day: number | null): MaybeNumber;
export function getReportTimePosition(time: MaybeNumber, day: number | null): MaybeNumber {
return time === null || day === null ? null : day * dayInMs + time;
}
export function getEventVariance(entry: OntimeEventReport | undefined): EventVariance {
if (!entry) return notRun;
const start = getReportTimePosition(entry.startedAt, entry.startedAtDay);
const end = getReportTimePosition(entry.endedAt, entry.endedAtDay);
if (start === null || end === null) return notRun;
const actualDuration = end - start;
const delta = actualDuration - entry.scheduledDuration;
if (Math.abs(delta) < MILLIS_PER_SECOND) return { actualDuration, delta, status: 'ontime' };
return { actualDuration, delta, status: delta > 0 ? 'over' : 'under' };
}
+35
View File
@@ -1,3 +1,5 @@
import { AppMode } from '../ontimeConfig';
declare module '*.scss' { declare module '*.scss' {
const content: Record<string, string>; const content: Record<string, string>;
export default content; export default content;
@@ -30,6 +32,39 @@ declare global {
} }
} }
/**
* Declare custom data we pass to the table
* - `handleUpdate` callback to update the entry when the user edits a cell
* - `handleUpdateTimer` callback to update the timer for a specific event
* - `options-showDelayedTimes` whether to show or hide delayed times
* - `options-hideTableSeconds` whether to hide seconds in the table
* - `options-hideIndexColumn` whether to hide the index column
* - `options-cuesheetMode` run or edit mode
*
* And metadata specific for each column
* - `canWrite` whether the user can write to this column
* - `colour` background colour associated with a custom field
*/
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData extends RowData> {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
hideIndexColumn: boolean;
cuesheetMode: AppMode;
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
canWrite: boolean;
colour?: string;
}
}
/** /**
* Allow passing CSS Properties * Allow passing CSS Properties
*/ */
@@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest';
import { matchesSettingsOptionQuery } from '../useAppSettingsMenu';
describe('matchesSettingsOptionQuery', () => {
const option = {
id: 'settings__general',
label: 'General settings',
keywords: ['pin', 'time zone'],
};
it('does not match an empty or whitespace-only query', () => {
expect(matchesSettingsOptionQuery(option, '')).toBe(false);
expect(matchesSettingsOptionQuery(option, ' ')).toBe(false);
});
it('matches keywords case-insensitively', () => {
expect(matchesSettingsOptionQuery(option, 'PIN')).toBe(true);
expect(matchesSettingsOptionQuery(option, 'TIME ZONE')).toBe(true);
});
});
@@ -1,4 +1,9 @@
$content-max-width: 1280px; .corner {
position: fixed;
top: 6rem;
right: 4rem;
z-index: $zindex-floating;
}
.contentWrapper { .contentWrapper {
display: flex; display: flex;
@@ -9,29 +14,9 @@ $content-max-width: 1280px;
position: relative; position: relative;
} }
.corner {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
// width is needed so the button can align to the right of the capped
// column, auto margins alone would shrink this to fit and centre it
width: 100%;
max-width: $content-max-width;
margin-inline: auto;
padding: 0 1rem 0.5rem;
box-sizing: border-box;
}
.content { .content {
box-sizing: border-box; margin: 1rem;
// as with .corner, the width is needed to fill the capped column, otherwise
// auto margins shrink this to fit and each panel ends up its own width
width: 100%;
max-width: $content-max-width;
margin: 0 auto 1rem;
padding-inline: 1rem;
overflow-y: auto; overflow-y: auto;
flex-grow: 1; flex-grow: 1;
// room for the last section to scroll to the top of the viewport padding-bottom: 300px;
padding-bottom: 40vh;
} }
@@ -12,12 +12,12 @@ interface PanelContentProps {
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) { export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return ( return (
<div className={style.contentWrapper}> <div className={style.contentWrapper}>
<div className={style.content}>{children}</div>
<div className={style.corner}> <div className={style.corner}>
<Button size='large' onClick={onClose}> <Button size='large' onClick={onClose}>
Close settings <IoClose /> Close settings <IoClose />
</Button> </Button>
</div> </div>
<div className={style.content}>{children}</div>
</div> </div>
); );
} }
@@ -1,13 +1,3 @@
.container {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-y: auto;
}
.tabs, .tabs,
ul { ul {
list-style: none; list-style: none;
@@ -16,20 +6,22 @@ ul {
} }
.tabs { .tabs {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto;
} }
.primary, .primary,
.secondary { .secondary {
padding: 0.25rem 1rem; padding: 0.25rem 1rem;
margin-right: 1rem; margin-right: 1rem;
border-radius: 2px;
&:focus-visible { &:focus {
background-color: $gray-1000; background-color: $gray-1000;
outline: 2px solid $blue-500; outline: 0;
outline-offset: -2px;
} }
&:hover { &:hover {
@@ -40,21 +32,14 @@ ul {
.primary { .primary {
font-size: 1rem; font-size: 1rem;
border-radius: 2px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border-left: 3px solid transparent;
&.groupActive {
color: $blue-400;
font-weight: 600;
}
&.active { &.active {
color: $ui-white; color: $blue-400;
border-left-color: $blue-400; background-color: $gray-1100;
background-color: $bg-active;
font-weight: 600;
} }
&.highlight { &.highlight {
@@ -77,9 +62,6 @@ ul {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
&.active { &.active {
color: $ui-white; color: $blue-400;
border-left-color: $blue-400;
background-color: $bg-active;
font-weight: 600;
} }
} }
@@ -1,18 +1,10 @@
import { Fragment, useState } from 'react'; import { Fragment } from 'react';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { isKeyEnter } from '../../../common/utils/keyEvent'; import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import * as Panel from '../panel-utils/PanelUtils'; import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import {
filterSettingsOptions,
matchesSettingsOptionQuery,
SettingsOption,
SettingsOptionId,
useAppSettingsMenu,
} from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation'; import useAppSettingsNavigation from '../useAppSettingsNavigation';
import SettingsSearch from './SettingsSearch';
import style from './PanelList.module.scss'; import style from './PanelList.module.scss';
@@ -24,57 +16,23 @@ interface PanelListProps extends PanelBaseProps {
selectedPanel: string; selectedPanel: string;
} }
/** Returns the first matching setting, preferring a matching child over its non-matching group. */
function getFirstResultId(results: SettingsOption[], query: string): SettingsOptionId | null {
const firstResult = results[0];
if (!firstResult) {
return null;
}
if (matchesSettingsOptionQuery(firstResult, query.trim().toLowerCase())) {
return firstResult.id as SettingsOptionId;
}
return (firstResult.secondary?.[0]?.id as SettingsOptionId | undefined) ?? null;
}
export default function PanelList({ selectedPanel, location }: PanelListProps) { export default function PanelList({ selectedPanel, location }: PanelListProps) {
const { options } = useAppSettingsMenu(); const { options } = useAppSettingsMenu();
const { setLocation } = useAppSettingsNavigation();
const [query, setQuery] = useState('');
const results = filterSettingsOptions(options, query);
const handleSearchSubmit = () => {
const target = getFirstResultId(results, query);
if (target) {
setLocation(target);
setQuery('');
}
};
return ( return (
<div className={style.container}> <ul className={style.tabs}>
<SettingsSearch query={query} onQueryChange={setQuery} onSubmit={handleSearchSubmit} /> {options.map((panel) => {
const isSelected = selectedPanel === panel.id;
{results.length === 0 ? ( if (panel.highlight) {
<Panel.EmptyState title='No settings match' description={`Nothing found for "${query.trim()}"`} /> return (
) : ( <Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<ul className={style.tabs}> <PanelListItem panel={panel} location={location} isSelected={isSelected} />
{results.map((panel) => { </Tooltip>
const isSelected = selectedPanel === panel.id; );
if (panel.highlight) { }
return ( return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
<Tooltip key={panel.id} text={panel.highlight} render={<span />}> })}
<PanelListItem panel={panel} location={location} isSelected={isSelected} /> </ul>
</Tooltip>
);
}
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
)}
</div>
); );
} }
@@ -86,15 +44,7 @@ interface PanelListItemProps {
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) { function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
const { setLocation } = useAppSettingsNavigation(); const { setLocation } = useAppSettingsNavigation();
const hasSelectedChild = Boolean( const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
isSelected && panel.secondary?.some((secondary) => secondary.id.split('__')[1] === location),
);
const classes = cx([
style.primary,
isSelected && !hasSelectedChild && style.active,
hasSelectedChild && style.groupActive,
panel.highlight && style.highlight,
]);
return ( return (
<Fragment key={panel.id}> <Fragment key={panel.id}>
@@ -125,7 +75,6 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
} }
}} }}
className={secondaryClasses} className={secondaryClasses}
tabIndex={0}
role='button' role='button'
> >
{secondary.label} {secondary.label}
@@ -1,24 +0,0 @@
.search {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
margin: 0.25rem 1rem 0.25rem 0.25rem;
}
.icon {
position: absolute;
left: 0.5rem;
color: $gray-400;
pointer-events: none;
}
.input {
padding-left: 2rem;
padding-right: 2rem;
}
.clear {
position: absolute;
right: 0.25rem;
}
@@ -1,60 +0,0 @@
import { KeyboardEvent, useRef } from 'react';
import { IoClose, IoSearch } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Input from '../../../common/components/input/input/Input';
import style from './SettingsSearch.module.scss';
interface SettingsSearchProps {
query: string;
onQueryChange: (query: string) => void;
onSubmit: () => void;
}
export default function SettingsSearch({ query, onQueryChange, onSubmit }: SettingsSearchProps) {
const searchRef = useRef<HTMLInputElement>(null);
const isSearching = query.trim().length > 0;
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape' && isSearching) {
// Do not let the settings panel close while the user is clearing a search.
event.stopPropagation();
onQueryChange('');
return;
}
if (event.key === 'Enter') {
onSubmit();
}
};
return (
<div className={style.search}>
<IoSearch className={style.icon} />
<Input
ref={searchRef}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={handleKeyDown}
placeholder='Search settings'
className={style.input}
fluid
/>
{isSearching && (
<IconButton
variant='ghosted-white'
size='small'
aria-label='Clear search'
className={style.clear}
onClick={() => {
onQueryChange('');
searchRef.current?.focus();
}}
>
<IoClose />
</IconButton>
)}
</div>
);
}
@@ -1,26 +1,24 @@
$inner-padding: 1rem; $inner-padding: 1rem;
$card-padding: 2rem;
.header { .header {
font-size: 1.5rem; font-size: 2rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10; border-bottom: 1px solid $white-10;
font-weight: 600; font-weight: 600;
} }
.subheader { .subheader {
font-size: 1.125rem; font-size: 1.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
} }
.title { .title {
font-size: 1rem; font-size: 1.375rem;
padding: 0 var(--panel-card-padding, #{$card-padding}); padding: 0 2rem;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -37,12 +35,10 @@ $card-padding: 2rem;
color: $ui-white; color: $ui-white;
} }
// width is owned by the panel content column, which caps and centres itself.
// sections must stay full width so top level cards line up with Panel.Header
// and nested sections fill their card rather than shrinking to fit
.section { .section {
position: relative; position: relative;
margin-top: 2rem; margin-top: 2rem;
max-width: 1024px;
} }
.indent { .indent {
@@ -55,10 +51,8 @@ $card-padding: 2rem;
} }
.card { .card {
--panel-card-padding: #{$card-padding};
position: relative; position: relative;
padding: var(--panel-card-padding); padding: 2rem;
background-color: $white-3; background-color: $white-3;
border: 1px solid $gray-1100; border: 1px solid $gray-1100;
border-radius: 3px; border-radius: 3px;
@@ -82,10 +76,10 @@ $card-padding: 2rem;
color: $error-red; color: $error-red;
} }
// tables scroll with the panel rather than owning a nested scroll area,
// which keeps the sticky table head anchored to the panel viewport
.pad { .pad {
padding: 0 var(--panel-card-padding, #{$card-padding}); padding: 0 2rem;
max-height: 550px;
overflow-y: scroll;
} }
.table { .table {
@@ -103,13 +97,10 @@ $card-padding: 2rem;
} }
th { th {
font-weight: 600; font-weight: 400;
color: $gray-300; color: $gray-400;
font-size: calc(1rem - 3px);
letter-spacing: 0.02em;
vertical-align: bottom;
white-space: nowrap; white-space: nowrap;
text-transform: uppercase; text-transform: capitalize;
} }
th, th,
@@ -121,17 +112,6 @@ $card-padding: 2rem;
background-color: $white-1; background-color: $white-1;
} }
// 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;
> td:first-child {
box-shadow: inset 3px 0 $blue-400;
font-weight: 500;
}
}
// allow highlighting table elements // allow highlighting table elements
tr[data-warn='true'], tr[data-warn='true'],
td[data-warn='true'] { td[data-warn='true'] {
@@ -140,7 +120,7 @@ $card-padding: 2rem;
} }
.listGroup { .listGroup {
padding: 0 var(--panel-card-padding, #{$card-padding}); padding: 0 2rem;
> li:not(:last-child) { > li:not(:last-child) {
border-bottom: 1px solid $white-10; border-bottom: 1px solid $white-10;
@@ -153,17 +133,6 @@ $card-padding: 2rem;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0.5rem 0; padding: 0.5rem 0;
&.interactive {
margin-inline: calc(var(--panel-card-padding, #{$card-padding}) * -1);
padding: 0.75rem var(--panel-card-padding, #{$card-padding});
transition: background-color $transition-time-action;
&:hover,
&:focus-within {
background-color: $white-3;
}
}
} }
.fieldTitle { .fieldTitle {
@@ -194,13 +163,14 @@ $card-padding: 2rem;
.divider { .divider {
border: none; border: none;
border-top: 1px solid $white-10; border-top: 1px solid $white-10;
margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1); margin: 1rem -2rem;
} }
.overlay { .overlay {
position: absolute; position: absolute;
z-index: $zindex-backdrop; z-index: $zindex-backdrop;
inset: 0; width: 100%;
height: 100%;
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
display: grid; display: grid;
place-content: center; place-content: center;
@@ -218,35 +188,19 @@ $card-padding: 2rem;
animation: animloader 1s ease-in infinite; animation: animloader 1s ease-in infinite;
} }
.emptyState { .empty {
padding: 3rem 1.5rem; background-color: $black-10;
text-align: center; text-align: center;
} color: $muted-gray;
.emptyMessage { td {
width: min(30rem, 100%); padding-block: 5rem;
margin-inline: auto; }
}
.emptyTitle { button {
margin-bottom: 0.25rem; margin-top: 1rem;
color: rgba($gray-200, 0.72); margin-inline: auto;
font-size: calc(1rem + 2px); }
font-weight: 400;
}
.emptyBody {
color: rgba($gray-200, 0.55);
font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1rem;
} }
.inlineElements { .inlineElements {
@@ -285,3 +239,14 @@ $card-padding: 2rem;
opacity: 0; opacity: 0;
} }
} }
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -1,5 +1,7 @@
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react'; import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { IoAdd } from 'react-icons/io5';
import Button from '../../../common/components/buttons/Button';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './PanelUtils.module.scss'; import style from './PanelUtils.module.scss';
@@ -60,29 +62,16 @@ export function Table({ className, children }: { className?: string; children: R
); );
} }
export interface EmptyStateProps { export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
title: string;
description?: ReactNode;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return ( return (
<div className={style.emptyState}> <tr className={style.empty}>
<div className={style.emptyMessage}>
<div className={style.emptyTitle}>{title}</div>
{description && <div className={style.emptyBody}>{description}</div>}
{action && <div className={style.emptyAction}>{action}</div>}
</div>
</div>
);
}
export function TableEmpty(props: EmptyStateProps) {
return (
<tr>
<td colSpan={99}> <td colSpan={99}>
<EmptyState {...props} /> <div>{label ?? 'No data yet'}</div>
{handleClick && (
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
New <IoAdd />
</Button>
)}
</td> </td>
</tr> </tr>
); );
@@ -92,8 +81,8 @@ export function ListGroup({ className, children }: { className?: string; childre
return <ul className={cx([style.listGroup, className])}>{children}</ul>; return <ul className={cx([style.listGroup, className])}>{children}</ul>;
} }
export function ListItem({ children, interactive = false }: { children: ReactNode; interactive?: boolean }) { export function ListItem({ children }: { children: ReactNode }) {
return <li className={cx([style.listItem, interactive && style.interactive])}>{children}</li>; return <li className={style.listItem}>{children}</li>;
} }
export function Field({ export function Field({
@@ -1,4 +1,4 @@
import Info from '../../../../common/components/info/Info'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import { import {
buyMeACoffeeUrl, buyMeACoffeeUrl,
discordUrl, discordUrl,
@@ -11,8 +11,6 @@ import {
} from '../../../../externals'; } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion'; import AppVersion from './AppVersion';
import CloudPanel from './CloudPanel';
import ExternalLinkRow from './ExternalLinkRow';
export default function AboutPanel() { export default function AboutPanel() {
return ( return (
@@ -21,49 +19,28 @@ export default function AboutPanel() {
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
<Panel.SubHeader>Ontime</Panel.SubHeader> <Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Divider /> <Panel.Paragraph>
<Info>Free, open-source software for managing rundowns and event timers.</Info> Free, open-source software for managing rundowns and event timers
<Panel.ListGroup> <ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
<ExternalLinkRow href={websiteUrl} title='www.getontime.no' description='App site and downloads' /> </Panel.Paragraph>
<ExternalLinkRow <Panel.Paragraph>
href={githubSponsorUrl} Considering sponsoring our work
title='GitHub Sponsors' <ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
description='Support development through GitHub' <ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
/> </Panel.Paragraph>
<ExternalLinkRow <Panel.Paragraph>
href={buyMeACoffeeUrl} If you are looking for an online version of Ontime, consider trying out our cloud service
title='Buy Me a Coffee' <ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
description='Make a one-time contribution' </Panel.Paragraph>
/>
</Panel.ListGroup>
</Panel.Card>
</Panel.Section>
<CloudPanel />
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<AppVersion />
</Panel.ListGroup>
</Panel.Card>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Helpful links</Panel.SubHeader>
<Panel.Divider />
<Panel.ListGroup>
<ExternalLinkRow
href={documentationUrl}
title='Read the docs'
description='Setup guides and feature documentation'
/>
<ExternalLinkRow href={githubUrl} title='GitHub' description='Report issues or contribute to Ontime' />
<ExternalLinkRow href={youtubeUrl} title='YouTube' description='Watch tutorials and feature walkthroughs' />
<ExternalLinkRow href={discordUrl} title='Discord' description='Get help and share suggestions' />
<ExternalLinkRow href={subredditUrl} title='Reddit' description='Join the Ontime community discussion' />
</Panel.ListGroup>
</Panel.Card> </Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion />
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
<ExternalLink href={youtubeUrl}>YouTube channel</ExternalLink>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
</Panel.Section> </Panel.Section>
</> </>
); );
@@ -1,7 +0,0 @@
.updateIndicator {
width: 0.5em;
height: 0.5em;
flex: 0 0 auto;
border-radius: 99px;
background-color: $red-400;
}
@@ -1,47 +1,35 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import useAppVersion from '../../../../common/hooks-query/useAppVersion'; import useAppVersion from '../../../../common/hooks-query/useAppVersion';
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals'; import { appVersion, isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import style from './AppVersion.module.scss';
export default function AppVersion() { export default function AppVersion() {
const { data, isError } = useAppVersion(); const { data, isError } = useAppVersion();
if (isError) { if (isError) {
return ( return (
<Panel.ListItem> <Panel.Paragraph>
<Panel.Field title={`Ontime ${appVersion}`} description='' error='Could not fetch version information' /> {`You are currently using Ontime version ${appVersion}`}
</Panel.ListItem> <Panel.Error>Could not fetch version information</Panel.Error>
</Panel.Paragraph>
); );
} }
if (data.hasUpdates) { if (data.hasUpdates) {
return ( return (
<Panel.ListItem> <Panel.Paragraph>
<Panel.Field {`You are currently using Ontime version ${appVersion}.`}
title={ <br />
<> <br />
<span className={style.updateIndicator} aria-hidden='true' /> {`A new version ${data.version} is available.`} <br />
{`Ontime ${appVersion}`} {isOntimeCloud ? (
</> 'You can restart your stage to get the latest available version.'
} ) : (
description={ <ExternalLink href={data.url}>Please visit the release page to download</ExternalLink>
isOntimeCloud
? `Version ${data.version} is available. Restart your stage to update.`
: `Version ${data.version} is available.`
}
/>
{!isOntimeCloud && (
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
)} )}
</Panel.ListItem> </Panel.Paragraph>
); );
} }
return ( return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>;
<Panel.ListItem>
<Panel.Field title={`Ontime ${appVersion}`} description='You are using the latest version.' />
</Panel.ListItem>
);
} }
@@ -1,74 +0,0 @@
.card {
padding: 1.5rem;
border-color: rgba($action-blue, 0.42);
background-color: rgba($action-blue, 0.06);
}
.eyebrow {
color: $action-text-color;
font-size: calc(1rem - 3px);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.title {
margin-top: 0.35rem;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.15;
}
.description {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 33rem;
margin-top: 0.9rem;
color: $gray-300;
line-height: 1.5;
}
.portability {
padding-top: 0.75rem;
border-top: 1px solid $white-10;
color: $gray-400;
font-size: calc(1rem - 2px);
}
.benefits {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem 1rem;
margin-top: 1.5rem;
padding: 0;
list-style: none;
li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
line-height: 1.4;
}
svg {
flex: 0 0 auto;
margin-top: 0.1rem;
color: $action-text-color;
font-size: 1.1rem;
}
}
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1.5rem;
}
@media (max-width: $min-tablet) {
.benefits {
grid-template-columns: 1fr;
}
}
@@ -1,52 +0,0 @@
import { IoCheckmarkCircle } from 'react-icons/io5';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import { isOntimeCloud, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './CloudPanel.module.scss';
export default function CloudPanel() {
if (isOntimeCloud) {
return null;
}
return (
<Panel.Section>
<Panel.Card className={style.card}>
<span className={style.eyebrow}>Ontime Cloud</span>
<h3 className={style.title}>Run Ontime online with zero setup</h3>
<p className={style.description}>
<span>
Ontime Cloud lets you run and share Ontime online with your team. Subscriptions support continued
development while Ontime remains fully open-source and self-hostable.
</span>
<span className={style.portability}>
Project files work locally and in Cloud, so your shows can move freely.
</span>
</p>
<ul className={style.benefits}>
<li>
<IoCheckmarkCircle />
Start in minutes, with no setup or maintenance
</li>
<li>
<IoCheckmarkCircle />
Coordinate multi-track rundowns with Overview views
</li>
<li>
<IoCheckmarkCircle />
Manage every instance from one dashboard
</li>
<li>
<IoCheckmarkCircle />
Share secure read-only views with collaborators
</li>
</ul>
<div className={style.actions}>
<ExternalLink href={websiteUrl}>Learn more about Ontime Cloud</ExternalLink>
</div>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,15 +0,0 @@
.link {
width: calc(100% + (var(--panel-card-padding, 2rem) * 2));
margin: -0.75rem calc(var(--panel-card-padding, 2rem) * -1);
padding: 0.75rem var(--panel-card-padding, 2rem);
> :first-child {
flex: 1;
min-width: 0;
}
}
.action {
color: $action-text-color;
font-size: calc(1rem - 2px);
}
@@ -1,21 +0,0 @@
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ExternalLinkRow.module.scss';
interface ExternalLinkRowProps {
href: string;
title: string;
description: string;
}
export default function ExternalLinkRow({ href, title, description }: ExternalLinkRowProps) {
return (
<Panel.ListItem interactive>
<ExternalLink href={href} className={style.link}>
<Panel.Field title={title} description={description} />
<span className={style.action}>Open</span>
</ExternalLink>
</Panel.ListItem>
);
}
@@ -1,10 +1,6 @@
.outerColumn { .outerColumn {
display: flex;
flex-direction: column;
gap: 2rem; gap: 2rem;
font-size: calc(1rem - 1px); margin-bottom: 2rem;
color: $ui-white;
padding-block: 0.5rem;
h3 { h3 {
font-size: 1rem; font-size: 1rem;
@@ -23,88 +19,61 @@
gap: 1rem; gap: 1rem;
} }
.titleSection { .titleSection,
.filterSection,
.oscSection,
.httpSection,
.actionSection {
display: grid; display: grid;
grid-template-columns: 1fr;
grid-gap: 0.5rem; grid-gap: 0.5rem;
button {
align-self: flex-end;
}
} }
.titleSection, .titleSection,
.ruleSection, .ruleSection,
.card { .filterSection,
label { .oscSection,
display: block; .httpSection,
.actionSection {
label,
div {
// we use the div as non-interactive placeholder for button cells
// it needs to match the size of the label element
font-size: calc(1rem - 3px); font-size: calc(1rem - 3px);
}
label {
color: $label-gray; color: $label-gray;
} }
} }
/** shared shell for a single filter or output */ .titleSection {
.card { grid-template-columns: 1fr;
border: 1px solid $white-10; }
.filterSection {
grid-template-columns: 2fr 1fr 2fr auto;
}
.oscSection {
grid-template-columns: 9rem 5rem 3fr 4fr auto;
}
.httpSection {
grid-template-columns: 1fr auto;
}
.actionSection {
grid-template-columns: auto 1fr 1fr auto;
.test {
grid-column: -1;
}
}
.outputCard {
border-left: 0.25rem solid $gray-1200; border-left: 0.25rem solid $gray-1200;
border-radius: $component-border-radius-md; padding-left: 0.5rem;
background-color: $black-10;
}
.cardHeader {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid $white-10;
}
/** pushes the actions to the end of the header, and absorbs any overflow */
.cardSummary {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: $aux-text-size;
color: $secondary-text-gray;
}
/**
* Two columns, so fields that belong together sit on one row: a host and its port,
* a filter field and its operator. Anything wider than half a card opts out with spanFull
*/
.cardBody {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem 0.75rem;
padding: 0.75rem;
}
/** for fields that read badly when narrow: OSC address and args, URLs, message text */
.spanFull {
grid-column: 1 / -1;
}
.testOk {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: $aux-text-size;
color: $green-400;
}
.testError {
padding: 0 0.75rem 0.5rem;
}
.tagOsc {
background-color: $blue-1000;
color: $blue-300;
}
.tagHttp {
background-color: $green-1000;
color: $green-300;
}
.tagOntime {
background-color: $gray-1000;
color: $gray-200;
} }
@@ -1,5 +1,14 @@
import { Automation, AutomationDTO, AutomationFilter, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types'; import {
import { useEffect, useMemo, useRef, useState } from 'react'; Automation,
AutomationDTO,
HTTPOutput,
OSCOutput,
OntimeAction,
isHTTPOutput,
isOSCOutput,
isOntimeAction,
} from 'ontime-types';
import { useEffect, useMemo } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5'; import { IoAdd, IoTrash } from 'react-icons/io5';
@@ -7,55 +16,37 @@ import { addAutomation, editAutomation, testOutput } from '../../../../common/ap
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../common/components/modal/Modal';
import RadioGroup from '../../../../common/components/radio-group/RadioGroup'; import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList, operators, type OutputErrors } from './automationUtils'; import { isAutomation, makeFieldList } from './automationUtils';
import HttpOutputForm from './HttpOutputForm';
import OntimeActionForm from './OntimeActionForm'; import OntimeActionForm from './OntimeActionForm';
import OscOutputForm from './OscOutputForm'; import TemplateInput from './template-input/TemplateInput';
import OutputCard, { type TestState } from './OutputCard';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation'; const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const formId = 'automation-form';
/** how long a successful test keeps its confirmation on screen */
const testFeedbackDuration = 2000;
interface AutomationFormProps { interface AutomationFormProps {
automation: Automation | AutomationDTO; automation: Automation | AutomationDTO;
onClose: () => void; onClose: () => void;
} }
/**
* Edits what an automation sends: its filters and its outputs.
* When it runs is a separate concept, owned by the triggers list and by the event editor.
*/
export default function AutomationForm({ automation, onClose }: AutomationFormProps) { export default function AutomationForm({ automation, onClose }: AutomationFormProps) {
const isEdit = isAutomation(automation); const isEdit = isAutomation(automation);
const { data } = useCustomFields(); const { data } = useCustomFields();
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const fieldList = useMemo(() => makeFieldList(data), [data]); const fieldList = useMemo(() => makeFieldList(data), [data]);
/**
* Test results are keyed by the field array id rather than the index:
* removing an output shifts every index after it, which would leave feedback on the wrong row
*/
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const { const {
clearErrors,
control, control,
handleSubmit, handleSubmit,
getValues, getValues,
@@ -101,28 +92,6 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
setFocus('title'); setFocus('title');
}, [setFocus]); }, [setFocus]);
// the timers outlive a fast close, clearing them avoids setting state on an unmounted form
useEffect(() => {
const timers = feedbackTimers.current;
return () => Object.values(timers).forEach(clearTimeout);
}, []);
const reportTest = (key: string, state: TestState) => {
setTestResults((prev) => ({ ...prev, [key]: state }));
clearTimeout(feedbackTimers.current[key]);
if (state.status === 'ok') {
feedbackTimers.current[key] = setTimeout(() => {
setTestResults((prev) => {
const { [key]: _discarded, ...rest } = prev;
return rest;
});
}, testFeedbackDuration);
}
};
const getOutputErrors = (index: number) => errors.outputs?.[index] as OutputErrors | undefined;
const handleAddNewFilter = () => { const handleAddNewFilter = () => {
appendFilter({ field: '', operator: 'equals', value: '' }); appendFilter({ field: '', operator: 'equals', value: '' });
}; };
@@ -140,280 +109,367 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
appendOutput({ type: 'ontime', action: 'aux1-start' }); appendOutput({ type: 'ontime', action: 'aux1-start' });
}; };
/** const handleTestOSCOutput = async (index: number) => {
* Sends a single output as configured, without saving the automation.
* OSC is fire and forget over UDP, so the most we can honestly claim is that we sent it.
*/
const handleTest = async (index: number, key: string) => {
const values = getValues(`outputs.${index}`);
if (isOSCOutput(values) && (!values.targetIP || !values.targetPort || !values.address)) {
reportTest(key, { status: 'error', message: 'Fill in the target and address before testing' });
return;
}
if (isHTTPOutput(values) && !values.url) {
reportTest(key, { status: 'error', message: 'Add a target URL before testing' });
return;
}
reportTest(key, { status: 'sending' });
try { try {
// NOTE: there is no meaningful validation to do on an Ontime action, we let the server deal with the data const values = getValues(`outputs.${index}`) as OSCOutput;
await testOutput(values); if (!values.targetIP || !values.targetPort || !values.address) {
reportTest(key, { status: 'ok', message: 'Sent' }); return;
} catch (error) { }
reportTest(key, { status: 'error', message: maybeAxiosError(error) }); await testOutput({
type: 'osc',
targetIP: values.targetIP,
targetPort: values.targetPort,
address: values.address,
args: values.args,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
}
};
const handleTestHTTPOutput = async (index: number) => {
try {
const values = getValues(`outputs.${index}`) as HTTPOutput;
if (!values.url) {
return;
}
await testOutput({
type: 'http',
url: values.url,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
}
};
const handleTestOntimeAction = async (index: number) => {
try {
const values = getValues(`outputs.${index}`) as OntimeAction;
// NOTE: there is no meaningful validation to do here, we let the server deal with the data
await testOutput({
...values,
type: 'ontime',
});
} catch (_error) {
/** we dont handle errors here */
} }
}; };
const onSubmit = async (values: AutomationDTO) => { const onSubmit = async (values: AutomationDTO) => {
// a stale failure from the previous attempt would otherwise sit under a successful retry if (isAutomation(automation)) {
clearErrors('root'); await handleEdit(automation.id, { id: automation.id, ...values });
} else {
try { await handleCreate(values);
if (isAutomation(automation)) {
await editAutomation(automation.id, { id: automation.id, ...values });
} else {
await addAutomation(values);
}
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
return;
} }
refetch(); refetch();
onClose();
};
/** describes a filter in plain language so the user does not have to read the form back to themselves */ async function handleEdit(id: string, values: Automation) {
const describeFilter = (index: number): string | null => { try {
const field = watch(`filters.${index}.field`); await editAutomation(id, values);
if (!field) { onClose();
return null; } catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
} }
const fieldLabel = fieldList.find((option) => option.value === field)?.label ?? field; async function handleCreate(values: AutomationDTO) {
const operator = watch(`filters.${index}.operator`); try {
const operatorLabel = operators.find((option) => option.value === operator)?.label ?? operator; await addAutomation(values);
const value = watch(`filters.${index}.value`); onClose();
} catch (error) {
return `${fieldLabel} ${operatorLabel} ${value ? `${value}` : 'nothing'}`; setError('root', { message: maybeAxiosError(error) });
}
}
}; };
// a failed save lands on `root`, which react-hook-form counts against isValid. const canSubmit = !isSubmitting && isDirty && isValid;
// Only errors on actual fields should stand between the user and another attempt
const invalidFields = Object.keys(errors).filter((field) => field !== 'root');
const canSubmit = !isSubmitting && isDirty && (isValid || invalidFields.length === 0);
return ( return (
<Modal <Panel.Indent
isOpen as='form'
onClose={onClose} name='automation-form'
showBackdrop onSubmit={handleSubmit(onSubmit)}
showCloseButton className={style.outerColumn}
size='compact' onKeyDown={(event) => preventEscape(event, onClose)}
title={isEdit ? 'Edit automation' : 'Create automation'} >
bodyElements={ <Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader>
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}> <div className={style.innerSection}>
<div className={style.innerColumn}> <h3>Automation options</h3>
<div className={style.titleSection}> <div className={style.titleSection}>
<label> <label>
Title Title
<Input <Input
{...register('title', { required: { value: true, message: 'Required field' } })} {...register('title', { required: { value: true, message: 'Required field' } })}
fluid fluid
placeholder='Load preset' placeholder='Load preset'
/> />
</label> </label>
<Panel.Error>{errors.title?.message}</Panel.Error> <Panel.Error>{errors.title?.message}</Panel.Error>
</div> </div>
</div> </div>
<div className={style.innerColumn}> <div className={style.innerSection}>
<h3>Filters (optional)</h3> <h3>Filters (optional)</h3>
<Panel.Description> <div className={style.ruleSection}>
Without filters the outputs are sent every time the automation is triggered. <label>
</Panel.Description> Trigger outputs if
<div className={style.ruleSection}> <RadioGroup
{fieldFilters.length > 1 && ( orientation='horizontal'
value={watch('filterRule')}
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
items={[
{ value: 'all', label: 'All filters pass' },
{ value: 'any', label: 'Any filter passes' },
]}
/>
</label>
{fieldFilters.map((field, index) => {
const key = `filters.${index}.field.${field.id}`;
return (
<div key={key} className={style.filterSection}>
<label> <label>
Trigger outputs if Runtime data source
<RadioGroup <Select<string | null>
orientation='horizontal' // need to normalize '' to null for the Select to show the placeholder
value={watch('filterRule')} value={watch(`filters.${index}.field`) || null}
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })} onValueChange={(value) => {
items={[ if (value === null) return;
{ value: 'all', label: 'All filters pass' }, setValue(`filters.${index}.field`, value, { shouldDirty: true });
{ value: 'any', label: 'Any filter passes' }, }}
]} options={fieldList.map(({ value, label }) => ({
value,
label,
disabled: value === null,
}))}
aria-label='Event field'
/> />
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
</label> </label>
)} <label>
{fieldFilters.map((field, index) => { Matching condition
const description = describeFilter(index); <Select
return ( value={watch(`filters.${index}.operator`)}
<div key={field.id} className={style.card}> onValueChange={(value: string | null) => {
<div className={style.cardHeader}> if (value === null) return;
<Tag>Filter</Tag> setValue(
<span className={style.cardSummary}>{description}</span> `filters.${index}.operator`,
<IconButton value as 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
aria-label='Delete filter' { shouldDirty: true },
variant='ghosted-destructive' );
onClick={() => removeFilter(index)} }}
> options={[
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'contains', label: 'contains' },
]}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label>
<label>
Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label>
<div>
<span>&nbsp;</span>
<div>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeFilter(index)}>
<IoTrash />
</IconButton>
</div>
</div>
</div>
);
})}
<div>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
</div>
</div>
</div>
<div className={style.innerColumn}>
<h3>Outputs</h3>
<Info>
Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself. <br /> <br />
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info>
{fieldOutputs.map((output, index) => {
if (isOSCOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
targetIP?: { message?: string };
targetPort?: { message?: string };
address?: { message?: string };
args?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>OSC</Tag>
<div className={style.oscSection}>
<label>
Target IP
<Input
{...register(`outputs.${index}.targetIP`, {
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
</div> </Panel.InlineElements>
<div className={style.cardBody}>
<label>
Runtime data source
<Select<string | null>
// need to normalize '' to null for the Select to show the placeholder
value={watch(`filters.${index}.field`) || null}
onValueChange={(value) => {
if (value === null) return;
setValue(`filters.${index}.field`, value, { shouldDirty: true });
}}
options={fieldList.map(({ value, label }) => ({
value,
label,
disabled: value === null,
}))}
aria-label='Event field'
/>
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
</label>
<label>
Matching condition
<Select
value={watch(`filters.${index}.operator`)}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(`filters.${index}.operator`, value as AutomationFilter['operator'], {
shouldDirty: true,
});
}}
options={operators}
aria-label='Operator'
/>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label>
<label className={style.spanFull}>
Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label>
</div>
</div> </div>
); </div>
})}
<div>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
</div> </div>
</div> );
</div> }
if (isHTTPOutput(output)) {
<div className={style.innerColumn}> const rowErrors = errors.outputs?.[index] as
<h3>Outputs</h3> | {
<Info> url?: { message?: string };
Type {'{{'} in any field to drop in Ontime runtime data, like the running event title.{' '} }
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink> | undefined;
</Info> return (
<div key={output.id} className={style.outputCard}>
{fieldOutputs.length === 0 && ( <Tag>HTTP</Tag>
<Panel.EmptyState <div className={style.httpSection}>
title='This automation does nothing yet' <label>
description='An automation without outputs will be triggered, but it has nothing to send.' Target URL
/> <TemplateInput
)} {...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
{fieldOutputs.map((output, index) => { pattern: {
const rowErrors = getOutputErrors(index); value: startsWithHttp,
const cardProps = { message: 'HTTP messages should target http:// or https://',
testState: testResults[output.id], },
onTest: () => handleTest(index, output.id), })}
onDelete: () => removeOutput(index), value={output.url}
}; fluid
placeholder='http://127.0.0.1/start/1'
if (isOSCOutput(output)) {
return (
<OutputCard
key={output.id}
label='OSC'
kindClass={style.tagOsc}
summary={watch(`outputs.${index}.address`)}
{...cardProps}
>
<OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
</OutputCard>
);
}
if (isHTTPOutput(output)) {
return (
<OutputCard key={output.id} label='HTTP' kindClass={style.tagHttp} {...cardProps}>
<HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
</OutputCard>
);
}
if (isOntimeAction(output)) {
return (
<OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
<OntimeActionForm
value={output.action}
index={index}
register={register}
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
/> />
</OutputCard> <Panel.Error>{rowErrors?.url?.message}</Panel.Error>
); </label>
} <div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
return null; if (isOntimeAction(output)) {
})} const rowErrors = errors.outputs?.[index] as
<div> | {
<DropdownMenu action?: { message?: string };
render={<Button />} time?: { message?: string };
items={[ text?: { message?: string };
{ visible?: { message?: string };
type: 'item', secondarySource?: { message?: string };
label: 'OSC', }
description: 'Send an OSC message to a device on the network', | undefined;
onClick: handleAddNewOSCOutput, return (
}, <div key={output.id} className={style.outputCard}>
{ <Tag>Ontime action</Tag>
type: 'item', <OntimeActionForm
label: 'HTTP', value={output.action}
description: 'Call a URL, for webhooks and REST APIs', index={index}
onClick: handleAddNewHTTPOutput, register={register}
}, rowErrors={rowErrors}
{ setValue={setValue}
type: 'item', watch={watch}
label: 'Ontime action', >
description: 'Change something inside Ontime, like a message or an aux timer', <span>&nbsp;</span>
onClick: handleAddnewOntimeAction, <Panel.InlineElements relation='inner'>
}, <Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
]} Test
> </Button>
Add output <IoAdd /> <IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
</DropdownMenu> <IoTrash />
</div> </IconButton>
</div> </Panel.InlineElements>
</form> </OntimeActionForm>
} </div>
footerElements={ );
<> }
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button> // there should be no other output types
<Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}> return null;
Save })}
<Panel.InlineElements relation='inner'>
<Button onClick={handleAddNewOSCOutput}>
OSC <IoAdd />
</Button> </Button>
</> <Button onClick={handleAddNewHTTPOutput}>
} HTTP <IoAdd />
/> </Button>
<Button onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd />
</Button>
</Panel.InlineElements>
</div>
<Panel.InlineElements align='end'>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
); );
} }
@@ -19,27 +19,24 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
return ( return (
<> <>
<Panel.Header>Automation</Panel.Header> <Panel.Header>Automation</Panel.Header>
<div ref={settingsRef}> <Panel.Section>
<AutomationSettingsForm <Panel.Loader isLoading={isLoading} />
enabledAutomations={data.enabledAutomations} <div ref={settingsRef}>
enabledOscIn={data.enabledOscIn} <AutomationSettingsForm
oscPortIn={data.oscPortIn} enabledAutomations={data.enabledAutomations}
automationState={automationState} enabledOscIn={data.enabledOscIn}
oscInputState={oscInputState} oscPortIn={data.oscPortIn}
isLoading={isLoading} automationState={automationState}
/> oscInputState={oscInputState}
</div> />
<div ref={automationsRef}> </div>
<AutomationsList <div ref={automationsRef}>
automations={data.automations} <AutomationsList automations={data.automations} enabledAutomations={automationState} />
triggers={data.triggers} </div>
enabledAutomations={automationState} <div ref={triggersRef}>
isLoading={isLoading} <TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} />
/> </div>
</div> </Panel.Section>
<div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} isLoading={isLoading} />
</div>
</> </>
); );
} }
@@ -1,4 +1,3 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { editAutomationSettings } from '../../../../common/api/automation'; import { editAutomationSettings } from '../../../../common/api/automation';
@@ -9,7 +8,6 @@ import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import Switch from '../../../../common/components/switch/Switch'; import Switch from '../../../../common/components/switch/Switch';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { preventEscape } from '../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex'; import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals'; import { isOntimeCloud } from '../../../../externals';
@@ -23,7 +21,6 @@ interface AutomationSettingsProps {
oscPortIn: number; oscPortIn: number;
automationState?: boolean; automationState?: boolean;
oscInputState?: boolean; oscInputState?: boolean;
isLoading?: boolean;
} }
export default function AutomationSettingsForm({ export default function AutomationSettingsForm({
@@ -32,9 +29,7 @@ export default function AutomationSettingsForm({
oscPortIn, oscPortIn,
automationState, automationState,
oscInputState, oscInputState,
isLoading,
}: AutomationSettingsProps) { }: AutomationSettingsProps) {
const { refetch } = useAutomationSettings();
const { const {
handleSubmit, handleSubmit,
reset, reset,
@@ -51,19 +46,10 @@ export default function AutomationSettingsForm({
}, },
}); });
// the panel renders before the query resolves, so the form is seeded with placeholder
// settings. Take the loaded ones when they arrive, as the other settings panels do
useEffect(() => {
reset({ enabledAutomations, enabledOscIn, oscPortIn });
}, [enabledAutomations, enabledOscIn, oscPortIn, reset]);
const onSubmit = async (formData: AutomationSettingsProps) => { const onSubmit = async (formData: AutomationSettingsProps) => {
try { try {
await editAutomationSettings(formData); await editAutomationSettings(formData);
reset(formData); reset(formData);
// the rest of the panel reads these flags from the query, which is otherwise only
// refreshed on a slow poll: refetch so a toggle takes effect where it is visible
await refetch();
} catch (error) { } catch (error) {
const message = maybeAxiosError(error); const message = maybeAxiosError(error);
setError('root', { message }); setError('root', { message });
@@ -79,130 +65,126 @@ export default function AutomationSettingsForm({
const oscInputEnabled = watch('enabledOscIn'); const oscInputEnabled = watch('enabledOscIn');
return ( return (
<Panel.Section> <Panel.Card>
<Panel.Card> <Panel.SubHeader>
<Panel.Loader isLoading={Boolean(isLoading)} /> Automation settings
<Panel.SubHeader> <Panel.InlineElements>
Automation settings <Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
<Panel.InlineElements> Revert to saved
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}> </Button>
Revert to saved <Button
</Button> variant='primary'
<Button type='submit'
variant='primary' form='automation-settings-form'
type='submit' disabled={!canSubmit}
form='automation-settings-form' loading={isSubmitting}
disabled={!canSubmit} >
loading={isSubmitting} Save
> </Button>
Save </Panel.InlineElements>
</Button> </Panel.SubHeader>
</Panel.InlineElements> {errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
<Info> <Info>
<span>Control Ontime and share its data with external systems in your workflow.</span> <span>Control Ontime and share its data with external systems in your workflow.</span>
<span>- An automation is what to send: OSC and HTTP messages, or an action inside Ontime.</span> <span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
<span>- A trigger is when to send it. Triggers for a single event live in the event editor.</span> <span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span> <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink> </Info>
</Info> </Panel.Section>
</Panel.Section>
<Panel.Section <Panel.Section
as='form' as='form'
id='automation-settings-form' id='automation-settings-form'
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)} onKeyDown={(event) => preventEscape(event, onReset)}
> >
<Panel.Title>Automation</Panel.Title> <Panel.Loader isLoading={false} />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title={
<>
<span>Enable automations</span>
{automationState === false && <Tag variant='warning'>OFF</Tag>}
</>
}
description={
automationState === false
? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
: 'Allow Ontime to send messages on lifecycle triggers'
}
descriptionTone={automationState === false ? 'warning' : 'default'}
error={errors.enabledAutomations?.message}
/>
<Switch
size='large'
checked={automationsEnabled}
onCheckedChange={(value: boolean) =>
setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Title>OSC Input</Panel.Title>
<Panel.ListGroup> <Panel.Title>Automation</Panel.Title>
{isOntimeCloud && ( <Panel.ListGroup>
<Info>For security reasons OSC integrations are not available in the cloud service.</Info> <Panel.ListItem>
)} <Panel.Field
<Panel.ListItem> title={
<Panel.Field <>
title={ <span>Enable automations</span>
<> {automationState === false && <Tag variant='warning'>OFF</Tag>}
<span>OSC input</span> </>
{oscInputState === false && <Tag variant='warning'>OFF</Tag>} }
</> description={
} automationState === false
description={ ? 'Automations are OFF. Triggers stay configured, but Ontime will not send messages.'
oscInputState === false : 'Allow Ontime to send messages on lifecycle triggers'
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.' }
: 'Allow control of Ontime through OSC' descriptionTone={automationState === false ? 'warning' : 'default'}
} error={errors.enabledAutomations?.message}
descriptionTone={oscInputState === false ? 'warning' : 'default'} />
error={errors.enabledOscIn?.message} <Switch
/> size='large'
<Switch checked={automationsEnabled}
size='large' onCheckedChange={(value: boolean) =>
checked={oscInputEnabled} setValue('enabledAutomations', value, { shouldDirty: true, shouldValidate: true })
onCheckedChange={(value: boolean) => }
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true }) />
} </Panel.ListItem>
/> </Panel.ListGroup>
</Panel.ListItem> <Panel.Title>OSC Input</Panel.Title>
<Panel.ListItem>
<Panel.Field <Panel.ListGroup>
title='Listen on port' {isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>}
description='Port for incoming OSC. Default: 8888' <Panel.ListItem>
error={errors.oscPortIn?.message} <Panel.Field
/> title={
<Input <>
id='oscPortIn' <span>OSC input</span>
placeholder='8888' {oscInputState === false && <Tag variant='warning'>OFF</Tag>}
maxLength={5} </>
style={{ textAlign: 'right', width: '5rem' }} }
type='number' description={
fluid oscInputState === false
{...register('oscPortIn', { ? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
required: { value: true, message: 'Required field' }, : 'Allow control of Ontime through OSC'
max: { value: 65535, message: 'Port must be within range 1024 - 65535' }, }
min: { value: 1024, message: 'Port must be within range 1024 - 65535' }, descriptionTone={oscInputState === false ? 'warning' : 'default'}
pattern: { error={errors.enabledOscIn?.message}
value: isOnlyNumbers, />
message: 'Value should be numeric', <Switch
}, size='large'
})} checked={oscInputEnabled}
/> onCheckedChange={(value: boolean) =>
</Panel.ListItem> setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
</Panel.ListGroup> }
</Panel.Section> />
</Panel.Card> </Panel.ListItem>
</Panel.Section> <Panel.ListItem>
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.oscPortIn?.message}
/>
<Input
id='oscPortIn'
placeholder='8888'
maxLength={5}
style={{ textAlign: 'right', width: '5rem' }}
type='number'
fluid
{...register('oscPortIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
); );
} }
@@ -1,20 +0,0 @@
/** tags make a cell taller than the title beside it, which staggers on the default baseline */
.table td {
vertical-align: middle;
}
.tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.actions {
justify-content: flex-end;
flex-wrap: nowrap;
}
.muted {
color: $muted-gray;
}
@@ -1,25 +1,18 @@
import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types'; import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { useMemo, useState } from 'react'; import { Fragment, useState } from 'react';
import { IoAdd, IoPencil, IoSparkles, IoTrash } from 'react-icons/io5'; import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
import { deleteAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import AutomationForm from './AutomationForm'; import AutomationForm from './AutomationForm';
import { summariseOutputs } from './automationOutputs';
import { groupTriggersByAutomation, isAutomation } from './automationUtils';
import DeleteAutomationDialog from './DeleteAutomationDialog';
import NewAutomationDialog from './NewAutomationDialog';
import { getLifecycleLabel } from './timerLifecycle';
import TriggerForm from './TriggerForm';
import style from './AutomationsList.module.scss'; const automationPlaceholder: AutomationDTO = {
const emptyAutomation: AutomationDTO = {
title: '', title: '',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
@@ -28,190 +21,112 @@ const emptyAutomation: AutomationDTO = {
interface AutomationsListProps { interface AutomationsListProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
triggers: Trigger[];
enabledAutomations?: boolean; enabledAutomations?: boolean;
isLoading: boolean;
} }
export default function AutomationsList({ export default function AutomationsList(props: AutomationsListProps) {
automations, const { automations, enabledAutomations } = props;
triggers,
enabledAutomations,
isLoading,
}: AutomationsListProps) {
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const [editing, setEditing] = useState<Automation | AutomationDTO | null>(null); const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [isPickingRecipe, setIsPickingRecipe] = useState(false); const [deleteError, setDeleteError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null);
/** the automation a new global trigger should point at, set from the row that asked for it */
const [triggerTarget, setTriggerTarget] = useState<Automation | null>(null);
const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]); const handleDelete = async (id: string) => {
const automationIds = Object.keys(automations); try {
setDeleteError(null);
/** a recipe creates the automation itself, so it lands in the list rather than in a form */ await deleteAutomation(id);
const handleCreated = async () => { } catch (error) {
setIsPickingRecipe(false); setDeleteError(maybeAxiosError(error));
await refetch(); } finally {
refetch();
}
}; };
const handleDeleted = async () => { const arrayAutomations = Object.keys(automations);
setDeleteTarget(null);
await refetch();
};
const handleTriggerCreated = async () => {
setTriggerTarget(null);
await refetch();
};
return ( return (
<Panel.Section> <Panel.Card>
<Panel.Card> <Panel.SubHeader>
{editing !== null && ( Manage automations
<AutomationForm <Button
// the form seeds itself from the automation once, so it must never be reused across two of them type='submit'
key={isAutomation(editing) ? editing.id : 'new'} disabled={Boolean(automationFormData)}
automation={editing} onClick={() => setAutomationFormData(automationPlaceholder)}
onClose={() => setEditing(null)} >
/> New <IoAdd />
)} </Button>
{isPickingRecipe && <NewAutomationDialog onClose={() => setIsPickingRecipe(false)} onCreated={handleCreated} />} </Panel.SubHeader>
{triggerTarget !== null && (
<TriggerForm
automations={automations}
trigger={null}
automationId={triggerTarget.id}
onCancel={() => setTriggerTarget(null)}
postSubmit={handleTriggerCreated}
/>
)}
{deleteTarget !== null && (
<DeleteAutomationDialog
automation={deleteTarget}
attachedTriggers={triggers.filter((trigger) => trigger.automationId === deleteTarget.id)}
onCancel={() => setDeleteTarget(null)}
onDeleted={handleDeleted}
/>
)}
<Panel.SubHeader>
Manage automations
<Panel.InlineElements>
<Button onClick={() => setIsPickingRecipe(true)}>
Start from recipe <IoSparkles />
</Button>
<Button onClick={() => setEditing(emptyAutomation)}>
New <IoAdd />
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.Section> {enabledAutomations === false && (
{enabledAutomations === false && ( <Info>
<Info> Automations are disabled. You can still manage automation definitions here, but they will not run until
Automations are disabled. You can still manage automation definitions here, but they will not run until enabled.
enabled. </Info>
</Info> )}
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.Table>
<thead>
<tr>
<th style={{ width: '45%' }}>Title</th>
<th style={{ width: '15%' }}>Trigger rule</th>
<th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Outputs</th>
<th />
</tr>
</thead>
<tbody>
{arrayAutomations.length === 0 && (
<Panel.TableEmpty
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
/>
)} )}
{arrayAutomations.map((automationId) => {
<Panel.Table className={style.table}> if (!Object.hasOwn(automations, automationId)) {
<thead> return null;
<tr> }
<th style={{ width: '35%' }}>Title</th> return (
<th style={{ width: '25%' }}>Runs on</th> <Fragment key={automationId}>
<th style={{ width: '15%' }}>Filter rule</th> <tr>
<th style={{ width: '15%' }}>Sends</th> <td>{automations[automationId].title}</td>
<th /> <td>
</tr> <Tag>{automations[automationId].filterRule}</Tag>
</thead> </td>
<tbody> <td>{automations[automationId].filters.length}</td>
{!isLoading && automationIds.length === 0 && ( <td>{automations[automationId].outputs.length}</td>
<Panel.TableEmpty <Panel.InlineElements align='end' relation='inner' as='td'>
title='No automations yet' <IconButton
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires. A recipe fills one in for a known workflow, like a video switcher or a chat channel.' variant='ghosted-white'
action={ aria-label='Edit entry'
<Panel.InlineElements> onClick={() => setAutomationFormData(automations[automationId])}
<Button variant='primary' onClick={() => setIsPickingRecipe(true)}> >
Start from recipe <IoSparkles /> <IoPencil />
</Button> </IconButton>
<Button onClick={() => setEditing(emptyAutomation)}> <IconButton
New automation <IoAdd /> variant='ghosted-destructive'
</Button> aria-label='Delete entry'
</Panel.InlineElements> onClick={() => handleDelete(automationId)}
} >
/> <IoTrash />
)} </IconButton>
{automationIds.map((automationId) => { </Panel.InlineElements>
const automation = automations[automationId]; </tr>
const lifecycles = lifecyclesByAutomation[automationId] ?? []; </Fragment>
const outputs = summariseOutputs(automation.outputs); );
})}
return ( {deleteError && (
<tr key={automationId}> <tr>
<td>{automation.title}</td> <td colSpan={5}>
<td> <Panel.Error>{deleteError}</Panel.Error>
{/* </td>
* Only global triggers are listed here: an automation can also be attached to </tr>
* single events, which live in the rundown. No global trigger therefore does not )}
* mean it never runs, so the cell offers to add one rather than claiming anything. </tbody>
*/} </Panel.Table>
{lifecycles.length === 0 ? ( </Panel.Card>
<Button size='small' variant='subtle' onClick={() => setTriggerTarget(automation)}>
Add trigger <IoAdd />
</Button>
) : (
<div className={style.tags}>
{lifecycles.map((cycle) => (
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
))}
</div>
)}
</td>
<td>
{automation.filters.length === 0 ? (
<span className={style.muted}></span>
) : (
<Tag>{automation.filterRule === 'all' ? 'All filters' : 'Any filter'}</Tag>
)}
</td>
<td>
<div className={style.tags}>
{outputs.length === 0 ? (
<Tag variant='warning'>No outputs</Tag>
) : (
outputs.map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))
)}
</div>
</td>
<td>
<div className={cx([style.tags, style.actions])}>
<IconButton
variant='ghosted-white'
aria-label='Edit entry'
onClick={() => setEditing(automation)}
>
<IoPencil />
</IconButton>
<IconButton
variant='ghosted-destructive'
aria-label='Delete entry'
onClick={() => setDeleteTarget(automation)}
>
<IoTrash />
</IconButton>
</div>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
); );
} }
@@ -1,96 +0,0 @@
import type { Automation, Trigger } from 'ontime-types';
import { useState } from 'react';
import { deleteAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Dialog from '../../../../common/components/dialog/Dialog';
import Info from '../../../../common/components/info/Info';
import * as Panel from '../../panel-utils/PanelUtils';
import { getLifecycleLabel } from './timerLifecycle';
interface DeleteAutomationDialogProps {
automation: Automation;
/** global triggers pointing at this automation, they are deleted along with it */
attachedTriggers: Trigger[];
onCancel: () => void;
onDeleted: () => void;
}
/**
* Deleting takes the automation's global triggers with it, so say so before it happens rather
* than leaving the user to discover it in the triggers list.
*
* An automation attached to an event is still refused by the server: that reference lives in
* the rundown and removing it is an edit to the show, not to this panel.
*/
export default function DeleteAutomationDialog({
automation,
attachedTriggers,
onCancel,
onDeleted,
}: DeleteAutomationDialogProps) {
const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
setError(null);
setIsDeleting(true);
try {
await deleteAutomation(automation.id);
onDeleted();
} catch (error) {
setError(maybeAxiosError(error));
} finally {
setIsDeleting(false);
}
};
return (
<Dialog
isOpen
onClose={onCancel}
showBackdrop
showCloseButton
title='Delete automation'
bodyElements={
<Panel.Section>
<Panel.Paragraph>
Delete <strong>{automation.title}</strong>? This cannot be undone.
</Panel.Paragraph>
{attachedTriggers.length > 0 && (
<Info type='warning'>
<Info.Title>
{attachedTriggers.length === 1
? 'Its trigger is deleted with it'
: `Its ${attachedTriggers.length} triggers are deleted with it`}
</Info.Title>
<Info.Body>{attachedTriggers.map((trigger) => getLifecycleLabel(trigger.trigger)).join(', ')}</Info.Body>
</Info>
)}
{error && (
<Info type='error'>
<Info.Title>Could not delete this automation</Info.Title>
<Info.Body>{error}</Info.Body>
<Info.Footer>
An automation attached to a single event has to be removed from that event first, in the event editor.
</Info.Footer>
</Info>
)}
</Panel.Section>
}
footerElements={
<>
<Button onClick={onCancel} disabled={isDeleting}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDelete} loading={isDeleting}>
Delete
</Button>
</>
}
/>
);
}
@@ -1,34 +0,0 @@
import type { AutomationDTO, HTTPOutput } from 'ontime-types';
import type { UseFormRegister } from 'react-hook-form';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
interface HttpOutputFormProps {
index: number;
output: HTTPOutput;
register: UseFormRegister<AutomationDTO>;
rowErrors?: OutputErrors;
}
export default function HttpOutputForm({ index, output, register, rowErrors }: HttpOutputFormProps) {
return (
<label className={style.spanFull}>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: { value: startsWithHttp, message: 'HTTP messages should target http:// or https://' },
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
);
}
@@ -1,196 +0,0 @@
/* ---------- step one: pick a recipe ---------- */
.picker {
display: flex;
flex-direction: column;
gap: 0.25rem;
color: $ui-white;
}
/** outside the scrolling list, so it stays put however many recipes there are */
.search {
position: relative;
display: flex;
align-items: center;
padding-block: 0.5rem;
}
/**
* Caps the list rather than fixing its height, so the dialog still shrinks to two results
* when a search narrows it down.
*/
.listViewport {
height: auto;
max-height: min(52vh, 30rem);
}
.list {
display: flex;
flex-direction: column;
// room for the overlay scrollbar beside the chevrons
padding-right: 0.5rem;
}
.searchIcon {
position: absolute;
left: 0.625rem;
color: $gray-400;
pointer-events: none;
}
.searchInput {
padding-left: 2rem;
padding-right: 2rem;
}
.searchClear {
position: absolute;
right: 0.25rem;
}
.group {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-top: 0.75rem;
&:first-child {
padding-top: 0;
}
}
.groupTitle {
margin: 0;
padding-inline: 0.125rem;
font-size: $aux-text-size;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: $gray-400;
}
.recipe {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.75rem;
text-align: left;
color: inherit;
background-color: transparent;
border: 1px solid transparent;
border-radius: $component-border-radius-md;
cursor: pointer;
&:hover {
background-color: $white-3;
border-color: $white-10;
}
&:focus-visible {
outline: 1px solid $action-blue;
outline-offset: -1px;
}
}
.recipeText {
display: flex;
flex-direction: column;
gap: 0.125rem;
flex: 1;
min-width: 0;
}
.recipeTitle {
font-weight: 600;
}
.recipeDescription {
font-size: $aux-text-size;
color: $secondary-text-gray;
}
.recipeTags {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.chevron {
color: $gray-400;
}
/* ---------- step two: answer what the recipe cannot know ---------- */
.setup {
display: flex;
flex-direction: column;
gap: 1rem;
color: $ui-white;
padding-block: 0.25rem;
}
.setupDescription {
margin: 0;
color: $secondary-text-gray;
}
/** the two facts a recipe decides for you, stated before the fields you can change */
.summary {
display: grid;
grid-template-columns: 5rem 1fr;
align-items: center;
gap: 0.5rem 0.75rem;
margin: 0;
padding: 0.75rem;
background-color: $black-10;
border: 1px solid $white-10;
border-radius: $component-border-radius-md;
dt {
font-size: $aux-text-size;
color: $label-gray;
}
dd {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
}
}
/* three columns, so a recipe's small numeric fields fill a row instead of leaving a hole */
.fields {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: start;
gap: 0.75rem;
@media (width < 40rem) {
grid-template-columns: repeat(2, 1fr);
}
}
/* an address or a sentence, which a third of a row cannot hold */
.wide {
grid-column: 1 / -1;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: $aux-text-size;
color: $label-gray;
}
.hint {
color: $secondary-text-gray;
}
/* pushes the leading action away from the confirming ones */
.apart {
margin-right: auto;
}
@@ -1,314 +0,0 @@
import type { Automation, TimerLifeCycle } from 'ontime-types';
import { useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { IoAdd, IoArrowBack, IoChevronForward, IoClose, IoSearch } from 'react-icons/io5';
import { addAutomation, addTrigger, editAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import { cx } from '../../../../common/utils/styleUtils';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import { summariseOutputs } from './automationOutputs';
import {
automationRecipes,
defaultValues,
needsTarget,
recipeCategoryLabels,
recipeCategoryOrder,
type AutomationRecipe,
type RecipeValues,
} from './automationRecipes';
import { makeTriggerTitle } from './automationUtils';
import { getLifecycleLabel } from './timerLifecycle';
import style from './NewAutomationDialog.module.scss';
interface NewAutomationDialogProps {
onClose: () => void;
onCreated: (automation: Automation) => void;
}
/**
* Builds a working automation for a known workflow.
*
* Two steps in one dialog rather than two stacked ones: pick a recipe, then answer only
* what that recipe cannot know where your gear is, how long the timer runs. Everything
* else the recipe already decided, which is the point of having recipes at all.
*/
export default function NewAutomationDialog({ onClose, onCreated }: NewAutomationDialogProps) {
const [selected, setSelected] = useState<AutomationRecipe | null>(null);
return selected === null ? (
<RecipePicker onClose={onClose} onSelect={setSelected} />
) : (
<RecipeSetup recipe={selected} onClose={onClose} onBack={() => setSelected(null)} onCreated={onCreated} />
);
}
/** matches on everything the user might type: the software, its protocol, the job it does */
function matches(recipe: AutomationRecipe, query: string): boolean {
const haystack = [recipe.title, recipe.description, recipeCategoryLabels[recipe.category], ...(recipe.keywords ?? [])]
.join(' ')
.toLowerCase();
return query
.toLowerCase()
.split(/\s+/)
.every((term) => haystack.includes(term));
}
interface RecipePickerProps {
onClose: () => void;
onSelect: (recipe: AutomationRecipe) => void;
}
function RecipePicker({ onClose, onSelect }: RecipePickerProps) {
const [query, setQuery] = useState('');
const available = useMemo(
() =>
// OSC is not available in the cloud service, offering those recipes there would be a lie
isOntimeCloud
? automationRecipes.filter(
(recipe) => !recipe.build(defaultValues(recipe)).outputs.some((output) => output.type === 'osc'),
)
: automationRecipes,
[],
);
const trimmed = query.trim();
const results = trimmed ? available.filter((recipe) => matches(recipe, trimmed)) : available;
const handleSearchKey = (event: KeyboardEvent<HTMLInputElement>) => {
// the dialog is the only thing listening for escape, and losing it while clearing a
// search would be a bigger surprise than the search staying put
if (event.key === 'Escape' && trimmed.length > 0) {
event.stopPropagation();
setQuery('');
return;
}
if (event.key === 'Enter' && results.length > 0) {
onSelect(results[0]);
}
};
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='compact'
title='Start from a recipe'
bodyElements={
<div className={style.picker}>
<div className={style.search}>
<IoSearch className={style.searchIcon} />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleSearchKey}
placeholder='Search recipes, eg. QLab, OSC, message'
className={style.searchInput}
aria-label='Search recipes'
fluid
autoFocus
/>
{trimmed.length > 0 && (
<IconButton
variant='ghosted-white'
size='small'
aria-label='Clear search'
className={style.searchClear}
onClick={() => setQuery('')}
>
<IoClose />
</IconButton>
)}
</div>
{results.length === 0 && (
<Panel.EmptyState
title='No recipe matches that'
description='Try the name of the software, or close this and build the automation yourself.'
/>
)}
<ScrollArea viewportClassName={style.listViewport} contentClassName={style.list}>
{recipeCategoryOrder.map((category) => {
const inCategory = results.filter((recipe) => recipe.category === category);
if (inCategory.length === 0) {
return null;
}
return (
<section key={category} className={style.group}>
<h4 className={style.groupTitle}>{recipeCategoryLabels[category]}</h4>
{inCategory.map((recipe) => (
<button type='button' key={recipe.id} className={style.recipe} onClick={() => onSelect(recipe)}>
<div className={style.recipeText}>
<div className={style.recipeTitle}>{recipe.title}</div>
<div className={style.recipeDescription}>{recipe.description}</div>
</div>
<div className={style.recipeTags}>
{recipe.triggers.map((cycle) => (
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
))}
<IoChevronForward className={style.chevron} />
</div>
</button>
))}
</section>
);
})}
</ScrollArea>
</div>
}
footerElements={<Button onClick={onClose}>Cancel</Button>}
/>
);
}
interface RecipeSetupProps {
recipe: AutomationRecipe;
onClose: () => void;
onBack: () => void;
onCreated: (automation: Automation) => void;
}
function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
const [values, setValues] = useState<RecipeValues>(() => defaultValues(recipe));
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const automation = recipe.build(values);
const isComplete = recipe.params.every(({ name }) => values[name]?.trim());
const setValue = (name: string, value: string) => setValues((prev) => ({ ...prev, [name]: value }));
/**
* What this dialog has already put on the server: creating takes one request per trigger
* on top of the automation itself, so a second attempt edits what exists and adds only
* the triggers still missing, rather than making a duplicate that fires the same cycles twice.
*/
const created = useRef<Automation | null>(null);
const createdCycles = useRef<Set<TimerLifeCycle>>(new Set());
const handleCreate = async () => {
setError(null);
setIsCreating(true);
try {
// the server generates the id, so the automation has to exist before a trigger points at it
const existing = created.current;
created.current = existing
? await editAutomation(existing.id, { id: existing.id, ...automation })
: await addAutomation(automation);
for (const cycle of recipe.triggers) {
if (createdCycles.current.has(cycle)) {
continue;
}
await addTrigger({
title: makeTriggerTitle(automation.title, cycle),
trigger: cycle,
automationId: created.current.id,
});
createdCycles.current.add(cycle);
}
onCreated(created.current);
} catch (error) {
// whatever landed is a normal automation, visible in the list. Report the failure and
// leave the retry to the user rather than rolling back work behind their back
setError(maybeAxiosError(error));
} finally {
setIsCreating(false);
}
};
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='compact'
title={recipe.title}
bodyElements={
<div className={style.setup}>
<p className={style.setupDescription}>{recipe.description}</p>
<dl className={style.summary}>
<dt>Runs on</dt>
<dd>
{recipe.triggers.map((cycle) => (
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
))}
</dd>
<dt>Sends</dt>
<dd>
{summariseOutputs(automation.outputs).map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))}
</dd>
</dl>
{recipe.params.length > 0 && (
<div className={style.fields}>
{recipe.params.map((param) => (
<label key={param.name} className={cx([style.field, param.wide && style.wide])}>
{param.label}
{param.type === 'choice' ? (
<Select
value={values[param.name]}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(param.name, value);
}}
options={param.options ?? []}
aria-label={param.label}
fluid
/>
) : (
<Input
type={param.type === 'number' ? 'number' : 'text'}
value={values[param.name]}
onChange={(event) => setValue(param.name, event.target.value)}
fluid
/>
)}
{param.hint && <span className={style.hint}>{param.hint}</span>}
</label>
))}
</div>
)}
<Panel.Description>
{needsTarget(recipe)
? 'Created as a normal automation. Nothing is sent until an event triggers it.'
: 'Created as a normal automation, which you can edit or delete like any other.'}
</Panel.Description>
</div>
}
footerElements={
<>
{error && <Panel.Error>{error}</Panel.Error>}
<Button variant='ghosted-white' className={style.apart} onClick={onBack} disabled={isCreating}>
<IoArrowBack /> All recipes
</Button>
<Button onClick={onClose} disabled={isCreating}>
Cancel
</Button>
<Button variant='primary' onClick={handleCreate} loading={isCreating} disabled={!isComplete}>
Create automation <IoAdd />
</Button>
</>
}
/>
);
}
@@ -1,11 +1,10 @@
import { AutomationDTO, OntimeAction, OntimeActionKey, SecondarySource } from 'ontime-types'; import { AutomationDTO, OntimeAction, OntimeActionKey, SecondarySource } from 'ontime-types';
import { useState } from 'react'; import { PropsWithChildren, useState } from 'react';
import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'; import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput'; import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
@@ -13,7 +12,13 @@ import style from './AutomationForm.module.scss';
interface OntimeActionFormProps { interface OntimeActionFormProps {
index: number; index: number;
register: UseFormRegister<AutomationDTO>; register: UseFormRegister<AutomationDTO>;
rowErrors?: OutputErrors; rowErrors?: {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
};
value: OntimeAction['action']; value: OntimeAction['action'];
watch: UseFormWatch<AutomationDTO>; watch: UseFormWatch<AutomationDTO>;
setValue: UseFormSetValue<AutomationDTO>; setValue: UseFormSetValue<AutomationDTO>;
@@ -25,8 +30,9 @@ export default function OntimeActionForm({
setValue, setValue,
rowErrors, rowErrors,
value, value,
children,
watch, watch,
}: OntimeActionFormProps) { }: PropsWithChildren<OntimeActionFormProps>) {
const [selectedAction, setSelectedAction] = useState<string>(value); const [selectedAction, setSelectedAction] = useState<string>(value);
const handleSetAction = (value: OntimeActionKey) => { const handleSetAction = (value: OntimeActionKey) => {
@@ -35,7 +41,7 @@ export default function OntimeActionForm({
}; };
return ( return (
<> <div className={style.actionSection}>
<label> <label>
Action Action
<Select <Select
@@ -89,7 +95,7 @@ export default function OntimeActionForm({
{selectedAction === 'message-set' && ( {selectedAction === 'message-set' && (
<> <>
<label className={style.spanFull}> <label>
Text (leave empty for no change) Text (leave empty for no change)
<TemplateInput <TemplateInput
{...register(`outputs.${index}.text`)} {...register(`outputs.${index}.text`)}
@@ -121,7 +127,7 @@ export default function OntimeActionForm({
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<> <>
<label className={style.spanFull}> <label>
Text (leave empty for no change) Text (leave empty for no change)
<TemplateInput <TemplateInput
{...register(`outputs.${index}.text`)} {...register(`outputs.${index}.text`)}
@@ -163,6 +169,8 @@ export default function OntimeActionForm({
</label> </label>
</> </>
)} )}
</>
<div className={style.test}>{children}</div>
</div>
); );
} }
@@ -1,63 +0,0 @@
import type { AutomationDTO, OSCOutput } from 'ontime-types';
import type { UseFormRegister } from 'react-hook-form';
import Input from '../../../../common/components/input/input/Input';
import * as Panel from '../../panel-utils/PanelUtils';
import type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
interface OscOutputFormProps {
index: number;
output: OSCOutput;
register: UseFormRegister<AutomationDTO>;
rowErrors?: OutputErrors;
}
export default function OscOutputForm({ index, output, register, rowErrors }: OscOutputFormProps) {
return (
<>
<label>
Target IP
<Input
{...register(`outputs.${index}.targetIP`, { required: { value: true, message: 'Required field' } })}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label className={style.spanFull}>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label className={style.spanFull}>
Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
</>
);
}
@@ -1,58 +0,0 @@
import type { ReactNode } from 'react';
import { IoCheckmark, IoTrash } from 'react-icons/io5';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './AutomationForm.module.scss';
export type TestState = { status: 'sending' | 'ok' | 'error'; message?: string };
interface OutputCardProps {
label: string;
kindClass?: string;
summary?: string;
testState?: TestState;
onTest: () => void;
onDelete: () => void;
children: ReactNode;
}
/**
* Shared chrome for every output kind: the type tag and the actions live in the header,
* so they stop competing with the form fields for grid columns
*/
export default function OutputCard({
label,
kindClass,
summary,
testState,
onTest,
onDelete,
children,
}: OutputCardProps) {
return (
<div className={style.card}>
<div className={style.cardHeader}>
<Tag className={kindClass}>{label}</Tag>
<span className={style.cardSummary}>{summary}</span>
{testState?.status === 'ok' && (
<span className={style.testOk}>
<IoCheckmark />
{testState.message}
</span>
)}
<Button variant='ghosted-white' onClick={onTest} loading={testState?.status === 'sending'}>
Test
</Button>
<IconButton aria-label='Delete output' variant='ghosted-destructive' onClick={onDelete}>
<IoTrash />
</IconButton>
</div>
{testState?.status === 'error' && <Panel.Error className={style.testError}>{testState.message}</Panel.Error>}
<div className={style.cardBody}>{children}</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { NormalisedAutomation, TimerLifeCycle, Trigger, TriggerDTO } from 'ontime-types'; import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -6,23 +6,30 @@ import { addTrigger, editTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils'; import { cycles } from './automationUtils';
const formId = 'trigger-form';
interface TriggerFormProps { interface TriggerFormProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
trigger: Trigger | null; initialId?: string;
/** preselects the automation for a new trigger, used when creating one from an automation row */ initialTitle?: string;
automationId?: string; initialAutomationId?: string;
initialTrigger?: TimerLifeCycle;
onCancel: () => void; onCancel: () => void;
postSubmit: () => void; postSubmit: () => void;
} }
export default function TriggerForm({ automations, trigger, automationId, onCancel, postSubmit }: TriggerFormProps) { export default function TriggerForm({
automations,
initialId,
initialTitle,
initialAutomationId,
initialTrigger,
onCancel,
postSubmit,
}: TriggerFormProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
@@ -33,9 +40,9 @@ export default function TriggerForm({ automations, trigger, automationId, onCanc
formState: { errors, isSubmitting, isValid, isDirty }, formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<TriggerDTO>({ } = useForm<TriggerDTO>({
defaultValues: { defaultValues: {
title: trigger?.title, title: initialTitle,
trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined), trigger: initialTrigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: trigger?.automationId ?? automationId ?? automations?.[Object.keys(automations)[0]]?.id, automationId: initialAutomationId ?? automations?.[Object.keys(automations)[0]]?.id,
}, },
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
@@ -48,9 +55,10 @@ export default function TriggerForm({ automations, trigger, automationId, onCanc
}, [setFocus]); }, [setFocus]);
const onSubmit = async (values: TriggerDTO) => { const onSubmit = async (values: TriggerDTO) => {
if (trigger) { // if we were passed an ID we are editing a Trigger
if (initialId) {
try { try {
await editTrigger(trigger.id, { id: trigger.id, ...values }); await editTrigger(initialId, { id: initialId, ...values });
postSubmit(); postSubmit();
} catch (error) { } catch (error) {
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` }); setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
@@ -77,63 +85,56 @@ export default function TriggerForm({ automations, trigger, automationId, onCanc
const canSubmit = isDirty && isValid; const canSubmit = isDirty && isValid;
return ( return (
<Modal <Panel.Indent
isOpen as='form'
onClose={onCancel} name='trigger-form'
showBackdrop onSubmit={handleSubmit(onSubmit)}
showCloseButton onKeyDown={(event) => preventEscape(event, onCancel)}
size='compact' >
title={trigger ? 'Edit trigger' : 'Create trigger'} <Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader>
bodyElements={ <label>
<form id={formId} onSubmit={handleSubmit(onSubmit)}> Title
<label> <Input
Title {...register('title', { required: { value: true, message: 'Required field' } })}
<Input fluid
{...register('title', { required: { value: true, message: 'Required field' } })} defaultValue={initialTitle}
fluid />
defaultValue={trigger?.title} <Panel.Error>{errors.title?.message}</Panel.Error>
/> </label>
<Panel.Error>{errors.title?.message}</Panel.Error> <label>
</label> Lifecycle trigger
<label> <Select
Lifecycle trigger value={watch('trigger')}
<Select onValueChange={(value) => {
value={watch('trigger')} if (value === null) return;
onValueChange={(value) => { setValue('trigger', value as TimerLifeCycle, { shouldDirty: true });
if (value === null) return; }}
setValue('trigger', value as TimerLifeCycle, { shouldDirty: true }); options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))}
}} aria-label='Lifecycle trigger'
options={cycles.map((cycle) => ({ value: cycle.value, label: cycle.label }))} />
aria-label='Lifecycle trigger' <Panel.Error>{errors.trigger?.message}</Panel.Error>
/> </label>
<Panel.Error>{errors.trigger?.message}</Panel.Error> <label>
</label> Automation title
<label> <Select
Automation title value={watch('automationId')}
<Select onValueChange={(value: string | null) => {
value={watch('automationId')} if (value === null) return;
onValueChange={(value: string | null) => { setValue('automationId', value, { shouldDirty: true });
if (value === null) return; }}
setValue('automationId', value, { shouldDirty: true }); options={automationSelect}
}} aria-label='Automation title'
options={automationSelect} />
aria-label='Automation title' <Panel.Error>{errors.automationId?.message}</Panel.Error>
/> </label>
<Panel.Error>{errors.automationId?.message}</Panel.Error> <Panel.InlineElements align='end'>
</label> <Button disabled={isSubmitting} onClick={onCancel}>
</form> Cancel
} </Button>
footerElements={ <Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
<> Save
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>} </Button>
<Button disabled={isSubmitting} onClick={onCancel}> </Panel.InlineElements>
Cancel </Panel.Indent>
</Button>
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
}
/>
); );
} }
@@ -5,37 +5,27 @@ import { IoAdd } from 'react-icons/io5';
import { deleteTrigger } from '../../../../common/api/automation'; import { deleteTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import { checkDuplicates } from './automationUtils'; import { checkDuplicates } from './automationUtils';
import TriggerForm from './TriggerForm'; import AutomationForm from './TriggerForm';
import TriggersListItem from './TriggersListItem'; import TriggersListItem from './TriggersListItem';
type FormState = {
isOpen: boolean;
trigger?: Trigger;
};
interface TriggersListProps { interface TriggersListProps {
triggers: Trigger[]; triggers: Trigger[];
automations: NormalisedAutomation; automations: NormalisedAutomation;
isLoading: boolean; enabledAutomations?: boolean;
} }
export default function TriggersList({ triggers, automations, isLoading }: TriggersListProps) { export default function TriggersList(props: TriggersListProps) {
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined }); const { triggers, automations, enabledAutomations } = props;
const [showForm, setShowForm] = useState(false);
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const { setLocation } = useAppSettingsNavigation();
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const openNewForm = () => setFormState({ isOpen: true });
const openEditForm = (trigger: Trigger) => setFormState({ isOpen: true, trigger });
const closeForm = () => setFormState({ isOpen: false, trigger: undefined });
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
setDeleteError(null);
await deleteTrigger(id); await deleteTrigger(id);
} catch (error) { } catch (error) {
setDeleteError(maybeAxiosError(error)); setDeleteError(maybeAxiosError(error));
@@ -45,110 +35,80 @@ export default function TriggersList({ triggers, automations, isLoading }: Trigg
}; };
const postSubmit = () => { const postSubmit = () => {
closeForm(); setShowForm(false);
refetch(); refetch();
}; };
const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]); const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]);
const orphans = useMemo(
() => triggers.filter((trigger) => !Object.hasOwn(automations, trigger.automationId)).length,
[triggers, automations],
);
// there is no point letting user creating a trigger if there are no automations // there is no point letting user creating a trigger if there are no automations
const canAdd = Object.keys(automations).length > 0; const canAdd = Object.keys(automations).length > 0;
return ( return (
<Panel.Section> <Panel.Card>
<Panel.Card> <Panel.SubHeader>
{formState.isOpen && ( Manage triggers
<TriggerForm <Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}>
automations={automations} New <IoAdd />
trigger={formState.trigger ?? null} </Button>
onCancel={closeForm} </Panel.SubHeader>
postSubmit={postSubmit} <Panel.Divider />
/> <Panel.Section>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
</Info>
)} )}
<Panel.SubHeader> {duplicates && (
Global triggers <Panel.Error>
<Button disabled={!canAdd} onClick={openNewForm}> You have created multiple links between the same trigger and automation which can performance issues.
New <IoAdd /> </Panel.Error>
</Button> )}
</Panel.SubHeader> {showForm && (
<Panel.Divider /> <AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
<Panel.Section> )}
<Panel.Description> <Panel.Table>
A global trigger runs an automation at a point in the timer lifecycle, whichever event is loaded. To run an <thead>
automation on one event only, add the trigger from the event editor instead. <tr>
</Panel.Description> <th style={{ width: '35%' }}>Title</th>
{duplicates && ( <th style={{ width: '25%' }}>Lifecycle trigger</th>
<Panel.Error> <th style={{ width: '25%' }}>Automation</th>
You have created multiple links between the same trigger and automation. Duplicate combinations will only <th />
fire once per lifecycle event. </tr>
</Panel.Error> </thead>
)} <tbody>
{orphans > 0 && ( {!showForm && triggers.length === 0 && (
<Panel.Error> <Panel.TableEmpty
{orphans === 1 label='Create a trigger to run an automation'
? '1 trigger points at an automation that no longer exists and will never run.' handleClick={canAdd ? () => setShowForm(true) : undefined}
: `${orphans} triggers point at automations that no longer exist and will never run.`} />
</Panel.Error> )}
)} {triggers.map((trigger, index) => {
<Panel.Table> return (
<thead> <Fragment key={trigger.id}>
<tr> <TriggersListItem
<th style={{ width: '35%' }}>Title</th> automations={automations}
<th style={{ width: '25%' }}>Lifecycle trigger</th> id={trigger.id}
<th style={{ width: '25%' }}>Automation</th> title={trigger.title}
<th /> trigger={trigger.trigger}
</tr> automationId={trigger.automationId}
</thead> duplicate={duplicates?.includes(index)}
<tbody> handleDelete={() => handleDelete(trigger.id)}
{!isLoading && triggers.length === 0 && ( postSubmit={postSubmit}
<Panel.TableEmpty />
title='No triggers yet' {deleteError && (
description={ <tr>
canAdd <td colSpan={5}>
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.' <Panel.Error>{deleteError}</Panel.Error>
: 'A trigger needs an automation to run. Create the automation first, then come back and decide when it should run.' </td>
} </tr>
action={ )}
canAdd ? ( </Fragment>
<Button variant='primary' onClick={openNewForm}> );
Create trigger <IoAdd /> })}
</Button> </tbody>
) : ( </Panel.Table>
<Button variant='primary' onClick={() => setLocation('automation__automations')}> </Panel.Section>
Go to automations </Panel.Card>
</Button>
)
}
/>
)}
{triggers.map((trigger, index) => {
return (
<Fragment key={trigger.id}>
<TriggersListItem
automations={automations}
trigger={trigger}
duplicate={duplicates?.includes(index)}
handleEdit={() => openEditForm(trigger)}
handleDelete={() => handleDelete(trigger.id)}
/>
</Fragment>
);
})}
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
); );
} }
@@ -1,21 +1,48 @@
import { NormalisedAutomation, Trigger } from 'ontime-types'; import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import { useState } from 'react';
import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5'; import { IoPencil, IoTrash, IoWarningOutline } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils'; import { cycles } from './automationUtils';
import AutomationForm from './TriggerForm';
interface TriggersListItemProps { interface TriggersListItemProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
trigger: Trigger; id: string;
title: string;
trigger: TimerLifeCycle;
automationId: string;
duplicate?: boolean; duplicate?: boolean;
handleEdit: () => void;
handleDelete: () => void; handleDelete: () => void;
postSubmit: () => void;
} }
export default function TriggersListItem(props: TriggersListItemProps) { export default function TriggersListItem(props: TriggersListItemProps) {
const { automations, trigger, duplicate, handleEdit, handleDelete } = props; const { automations, id, title, trigger, automationId, duplicate, handleDelete, postSubmit } = props;
const [isEditing, setIsEditing] = useState(false);
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<AutomationForm
automations={automations}
initialId={id}
initialTitle={title}
initialTrigger={trigger}
initialAutomationId={automationId}
onCancel={() => setIsEditing(false)}
postSubmit={() => {
setIsEditing(false);
postSubmit();
}}
/>
</td>
</tr>
);
}
return ( return (
<tr data-warn={duplicate}> <tr data-warn={duplicate}>
@@ -25,21 +52,16 @@ export default function TriggersListItem(props: TriggersListItemProps) {
color='#FFBC56' // $orange-500 color='#FFBC56' // $orange-500
/> />
)} )}
{trigger.title} {title}
</Panel.InlineElements> </Panel.InlineElements>
<td> <td>
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag> <Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
</td> </td>
<td> <td>
{/* a trigger can outlive the automation it points at, say after a partial project import */} <Tag>{automations?.[automationId]?.title}</Tag>
{automations?.[trigger.automationId] ? (
<Tag>{automations[trigger.automationId].title}</Tag>
) : (
<Tag variant='warning'>Missing automation</Tag>
)}
</td> </td>
<Panel.InlineElements align='end' relation='inner' as='td'> <Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}> <IconButton variant='ghosted-white' aria-label='Edit entry' onClick={() => setIsEditing(true)}>
<IoPencil /> <IoPencil />
</IconButton> </IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}> <IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>

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