Compare commits

..

6 Commits

Author SHA1 Message Date
Claude fe96941737 refactor: limit runtimeState core exports to consumed seams
Only the cores exercised by tests remain exported (startCore,
addTimeCore, updateCore, getExpectedTimesCore,
loadGroupFlagAndEndCore). The rest stay parameterized but module
private until a consumer needs them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
2026-07-04 14:54:56 +00:00
Claude ee9ac492c3 test: cover hot reload and add direct calculation tests for runtimeState cores
- characterisation scenarios for the hot-reload path (updateAll and
  updateLoaded), which was previously untested: editing the running
  event, removing the running event, and re-arming the loaded event
- direct tests for the parameterized cores against plain state objects
  with no module mocks: the update tick arithmetic and finish trigger
  window, added-time edge cases, and a documented matrix of the
  expected-times projection (offset sign, gap absorption, linked
  events, relative mode, delays and day offsets)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
2026-07-04 14:53:01 +00:00
Claude 4a0a174eb9 refactor: extract broadcast diffing into a testable function
The state diffing and gating logic moves verbatim from the
broadcastResult decorator into collectRuntimeStateChanges in
runtime.utils.ts. The decorator becomes a thin shell: snapshot, diff,
batch the changed keys, save the restore point, send.

The entire gating matrix is now unit tested against plain state
objects (see runtime.utils.test.ts), including the characterised
entry-diffing short-circuit, without sockets or module mocking. The
broadcast-contract tests remain the end-to-end lock and are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
2026-07-04 10:01:02 +00:00
Claude 56257adcbd perf: hoist update() inner functions out of the tick path
updateIfIdle and updateIfWaitingToRoll were declared inside update(),
allocating two closures on every 32ms tick. They are now module-level
functions receiving the state explicitly. Behaviour is unchanged, as
proven by the characterisation suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
2026-07-04 09:57:06 +00:00
Claude b10c38514f refactor: parameterize runtimeState mutators on an explicit state
Every mutator in runtimeState.ts now has a *Core function that receives
the state it operates on and touches nothing else. The module keeps the
singleton and exports thin wrappers with the existing names and
signatures, so all call sites (EventTimer, runtime service, rundown
service) are unchanged and the hot path gains no allocations.

This also removes the partial-injection hazards of the previous seams:
start(state) wrote timer.phase, offset and expected times to the module
singleton rather than the received state, and stop(state) cleared the
singleton. Production behaviour is identical since production always
operated on the singleton; the characterisation harness and snapshots
are untouched and prove it.

The cores can now be unit tested against a plain state object without
the singleton, module resets or the rundown cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
2026-07-04 09:55:56 +00:00
Claude 80cb1f96e7 test: add characterisation harness for the runtimeState machinery
Locks down the current behaviour of the runtime state hot path before
any refactoring work:

- scenario driver and fixtures that script playback sequences against
  the real runtimeState singleton with fake timers, mirroring the
  EventTimer tick loop
- characterisation catalogue covering absolute/relative offsets,
  expected group/flag/rundown times, gap compensation, delays,
  countToEnd, addTime edge cases, pause accounting, roll modes and
  playback across midnight
- broadcast-contract tests locking which top-level store keys are
  emitted per action and tick, including the per-second gating and
  integration throttling
- unit tests for the previously untested change-detection predicates
  in runtime.utils.ts

The tests characterise current behaviour, including quirks (documented
inline): the entry diffing short-circuit that drips changed entries one
per broadcast, the sticky forceFinish flag after negative addTime, and
roll-continuation overwriting actualStart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PVTnCfesGNGwPQJwJ9FwoD
2026-07-03 17:05:30 +00:00
322 changed files with 8487 additions and 13405 deletions
-5
View File
@@ -1,5 +0,0 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
-46
View File
@@ -1,46 +0,0 @@
---
name: code-review
description: Review Ontime changes for concrete correctness, architecture, testing, routing, security, and maintenance issues. For GitHub Copilot review.
---
# Review Ontime changes
Review as a maintainer. Find material defects, not speculative advice, style preferences, or generic checklists. Approve when no material issue remains.
## Load relevant context
Read the full diff, PR description, linked issue, tests, and nearby owning code. Then load only applicable guides:
- Every non-trivial review: [change assessment](../../../docs/agent-guides/change-assessment.md)
- Module placement, server layers, client state, shared packages: [architecture](../../../docs/agent-guides/architecture.md)
- Tests or changed behaviour: [testing](../../../docs/agent-guides/testing.md)
- Comments, abstractions, naming, complexity: [code quality](../../../docs/agent-guides/code-quality.md)
- Authentication, external input, files, integrations, assets, secrets: [security](../../../docs/agent-guides/security.md)
- Routes, URLs, websockets, authentication, cookies, presets, assets: [routing and cloud](../../../docs/agent-guides/routing-and-cloud.md)
- Rundowns, timers, persistence, imports, cache, realtime: [domain invariants](../../../docs/agent-guides/domain-invariants.md)
- Commands, imports, dependencies, formatting, CI claims: [workflow](../../../docs/agent-guides/workflow.md)
Check a guide and nearby canonical code before citing an Ontime convention. Skip unrelated guides.
## Review order
1. Establish intent and affected runtime surfaces.
2. Read tests first. Identify claimed behaviour and coverage.
3. Trace implementation, errors, and state transitions.
4. Check correctness/data integrity, security, architecture/testability, cloud routing, lifecycle/performance, maintainability.
5. Verify claimed checks. Never claim unobserved results.
Passing tests do not prove architecture, routing, comments, or error paths. Review changed behaviour only; include existing problems only when the diff worsens or relies on them.
## Findings
Report only concrete, actionable issues. Each finding: tight line range, direct defect, impact and trigger, smallest viable remedy when unclear.
- **P0 — Critical:** data loss, exploitable vulnerability, broadly broken production. Blocks merge.
- **P1 — High:** likely correctness failure or major supported-deployment regression. Blocks merge.
- **P2 — Medium:** real edge-case defect, architecture regression, missing business-rule test, stale comment, meaningful maintenance risk. Normally blocks merge.
- **P3 — Low:** local improvement with limited impact. No subjective style or tool-managed formatting.
Order by priority. Prefer few high-confidence findings. No praise or checklist before findings. If none, say so and note verification gaps or residual risk.
Then give one concise PR-level value/risk/complexity assessment. Never repeat it per finding.
-22
View File
@@ -1,22 +0,0 @@
# Ontime agent guide
Make the smallest maintainable change. Keep scope narrow. Inspect nearby code first. Reuse helpers and boundaries when semantics match.
## Load only relevant guides
- Commands, validation, formatting, imports, PRs: [workflow](docs/agent-guides/workflow.md).
- Non-trivial planning, implementation, review: [change assessment](docs/agent-guides/change-assessment.md).
- Module placement, server layers, client state, shared packages: [architecture](docs/agent-guides/architecture.md).
- Tests or changed behaviour: [testing](docs/agent-guides/testing.md).
- Comments, naming, abstractions, maintainability: [code quality](docs/agent-guides/code-quality.md).
- Authentication, external input, files, integrations, assets, secrets: [security](docs/agent-guides/security.md).
- Navigation, URLs, API paths, websockets, redirects, cookies, static assets: [routing and cloud](docs/agent-guides/routing-and-cloud.md).
- Rundowns, timers, imports, persistence, cache, websockets: [domain invariants](docs/agent-guides/domain-invariants.md).
Load multiple guides when needed. Skip unrelated guides for mechanical work.
## Before handoff
- Check the final diff for scope, stale comments, temporary code, redundant tests, generated files.
- If work reveals a missing, stable, reusable system, domain, or product invariant, update its owning guide. Exclude guesses, one-off bugs, and implementation details.
- Follow [workflow verification](docs/agent-guides/workflow.md). Report only observed results.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.13.0",
"version": "4.10.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+8 -9
View File
@@ -1,10 +1,10 @@
{
"name": "ontime-ui",
"version": "4.13.0",
"version": "4.10.0",
"private": true,
"type": "module",
"dependencies": {
"@base-ui/react": "1.7.0",
"@base-ui/react": "1.6.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -13,12 +13,12 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^9.5.1",
"@mantine/hooks": "^8.3.7",
"@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^9.1.2",
"@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1",
"axios": "^1.18.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0",
"react-icons": "5.7.0",
"react-icons": "5.6.0",
"react-router": "^8.0.1",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.14"
@@ -60,8 +60,7 @@
]
},
"devDependencies": {
"@sentry/vite-plugin": "5.4.0",
"@types/node": "catalog:",
"@sentry/vite-plugin": "5.1.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
@@ -73,8 +72,8 @@
"ontime-utils": "workspace:*",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "8.2.1",
"vite-plugin-compression2": "2.5.3",
"vite": "8.0.1",
"vite-plugin-compression2": "2.5.1",
"vite-plugin-svgr": "4.5.0",
"vitest": "catalog:"
}
-17
View File
@@ -6,7 +6,6 @@ import {
ProjectRundownsList,
RenumberCues,
Rundown,
RundownImportPayload,
TransientEventPayload,
} from 'ontime-types';
@@ -83,15 +82,6 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
return axios.delete(`${rundownPath}/${rundownId}`);
}
/**
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
*/
export async function importRundownWithOptions(
payload: RundownImportPayload,
): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/import`, payload);
}
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
@@ -176,13 +166,6 @@ export async function postCloneEntry(
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
*/
@@ -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 {
grid-area: input;
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
color: $label-gray;
}
.inlineEntry {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
grid-template-areas:
'label label'
'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
}
.redirect {
margin-left: auto;
grid-area: btn;
}
.label {
color: $gray-200;
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;
}
color: $label-gray;
}
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5';
import { baseURI } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import { setClientRemote } from '../../hooks/useSocket';
@@ -9,7 +8,7 @@ import Button from '../buttons/Button';
import Dialog from '../dialog/Dialog';
import Info from '../info/Info';
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 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 settingsUrl = `${window.location.origin}${baseURI}/?settings=sharing__presets`;
const viewOptions = [
...navigatorConstants.map((view) => ({
@@ -68,46 +66,34 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
bodyElements={
<>
<Info>
<Info.Body>
Remotely redirect the client to a different URL. Choose a custom path or select a URL Preset.
</Info.Body>
<Info.Footer>
<ExternalLink href={settingsUrl}>Manage URL Presets</ExternalLink>
</Info.Footer>
Remotely redirect the client to a different URL. <br />
Either by entering a custom path or selecting a URL Preset.
<br />
<br />
<AppLink search='settings=sharing__presets'>Manage URL Presets</AppLink>
</Info>
<section className={style.section}>
<div className={style.sectionHeader}>
<span className={style.label}>Enter custom path</span>
<span className={style.description}>Type the path the client should open.</span>
</div>
<div className={style.inlineEntry}>
<label className={style.textEntry}>
<span className={style.origin}>{origin}</span>
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
</label>
<Button
variant='primary'
aria-label='Redirect to custom path'
disabled={path === currentPath || path === ''}
className={style.redirect}
onClick={() => handleRedirect(path)}
>
Redirect
<IoArrowForward />
</Button>
</div>
</section>
<div className={style.separator} role='separator'>
<span>Or</span>
<div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
{origin}
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
</label>
<Button
variant='primary'
aria-label='Redirect'
disabled={path === currentPath || path === ''}
className={style.redirect}
onClick={() => handleRedirect(path)}
>
Redirect
<IoArrowForward />
</Button>
</div>
<section className={style.section}>
<div className={style.sectionHeader}>
<span className={style.label}>Select view or URL Preset</span>
<span className={style.description}>Choose from the available destinations.</span>
</div>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.inlineEntry}>
<label className={style.textEntry}>
<span className={style.origin}>{origin}</span>
{origin}
<Select
fluid
options={viewOptions}
@@ -129,7 +115,7 @@ export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onC
Redirect <IoArrowForward />
</Button>
</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 Input from '../input/input/Input';
import style from './RenameClientModal.module.scss';
interface RenameClientModalProps {
id: string;
name?: string;
@@ -33,22 +31,9 @@ export function RenameClientModal({ id, name: currentName = '', isOpen, onClose
isOpen={isOpen}
title={`Rename Client: ${currentName}`}
showCloseButton
showBackdrop
onClose={onClose}
bodyElements={
<div className={style.form}>
<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>
<Input height='large' placeholder='New name' value={name} onChange={(event) => setName(event.target.value)} />
}
footerElements={
<>
@@ -1,16 +1,12 @@
.dialog {
position: fixed;
top: 10vh;
top: 10%;
left: 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;
min-width: min(600px, 90vw);
background-color: $gray-1250;
color: $ui-white;
@@ -47,8 +43,7 @@
flex-direction: column;
gap: 0.5rem;
flex: 1;
min-height: 0;
max-height: min(80vh, 600px);
overflow-y: auto;
}
@@ -58,5 +53,4 @@
align-items: center;
justify-content: end;
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>;
}
@@ -1,14 +0,0 @@
.kbd {
display: inline-block;
min-width: 1.5rem;
padding: 0.0625rem 0.375rem;
border-radius: 2px;
background-color: $gray-1100;
color: $ui-white;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.4);
font-family: monospace;
font-size: calc(1rem - 4px);
line-height: 1.5;
text-align: center;
white-space: nowrap;
}
@@ -1,7 +0,0 @@
import { PropsWithChildren } from 'react';
import style from './Kbd.module.scss';
export default function Kbd({ children }: PropsWithChildren) {
return <kbd className={style.kbd}>{children}</kbd>;
}
@@ -21,15 +21,4 @@
outline: none;
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 { HiArrowLongRight } from 'react-icons/hi2';
import { IoOpenOutline } from 'react-icons/io5';
import { openLink } from '../../../utils/linkUtils';
import { cx } from '../../../utils/styleUtils';
@@ -10,10 +10,9 @@ interface ExternalLinkProps {
href: string;
children: ReactNode;
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) => {
event.preventDefault();
openLink(href);
@@ -24,11 +23,10 @@ export default function ExternalLink({ href, inline, className, children }: Exte
href='#!'
target='_blank'
rel='noreferrer'
className={cx([style.link, inline && style.inline, className])}
className={cx([style.link, inline && style.inline])}
onClick={handleClick}
>
{children}
<HiArrowLongRight className={style.icon} />
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
</a>
);
}
@@ -4,13 +4,10 @@
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
width: min(880px, 90vw);
padding-inline: 1rem;
min-width: min(880px, 90vw);
min-height: min(200px, 10vh);
max-height: calc(90vh - 1rem);
display: flex;
flex-direction: column;
max-width: min(1200px, 90vw);
background-color: $gray-1250;
color: $ui-white;
@@ -20,21 +17,14 @@
outline: none;
}
.compact {
min-width: 0;
width: min(680px, 90vw);
}
.small {
min-width: 0;
width: min(480px, 90vw);
}
.wide {
top: 4vh;
width: min(1800px, 98vw);
height: min(88vh, calc(96vh - 1rem));
min-height: 0;
min-width: min(1280px, 96vw);
max-width: min(1600px, 96vw);
height: 88vh;
max-height: 88vh;
display: flex;
flex-direction: column;
}
.backdrop {
@@ -51,19 +41,15 @@
.title {
padding-block: 1rem;
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.titleText {
min-width: 0;
font-size: 1rem;
font-weight: 600;
overflow-wrap: anywhere;
}
.body {
@@ -71,23 +57,21 @@
flex-direction: column;
gap: 0.5rem;
flex: 1;
min-height: 0;
padding: 0.5rem 1rem;
max-height: 60vh;
overflow-y: auto;
scroll-padding-block: 0.5rem;
}
.wide .body {
flex: 1;
min-height: 0;
max-height: none;
overflow: hidden;
}
.footer {
padding-block: 1rem;
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: end;
gap: 1rem;
flex-wrap: wrap;
}
@@ -12,7 +12,7 @@ interface ModalProps {
title?: string;
showCloseButton?: boolean;
showBackdrop?: boolean;
size?: 'small' | 'compact' | 'default' | 'wide';
size?: 'default' | 'wide';
bodyElements: ReactNode;
footerElements?: ReactNode;
onClose: () => void;
@@ -38,7 +38,7 @@ export default function Modal({
>
<BaseDialog.Portal>
{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}>
{title ? <BaseDialog.Title className={style.titleText}>{title}</BaseDialog.Title> : <div />}
{showCloseButton && (
@@ -38,6 +38,8 @@ $progress-bar-br: 3px;
.multiprogress-bar__indicator-bar {
background-color: var(--background-color-override, $ui-black);
opacity: 0.8;
transition: 1s linear;
transition-property: width;
.multiprogress-bar--ignore-css-override & {
background-color: $ui-black;
@@ -1,6 +1,5 @@
import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils';
@@ -9,7 +8,6 @@ import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: MaybeNumber;
complete: MaybeNumber;
eventId?: string | null;
normalColor: string;
warning?: MaybeNumber;
warningColor: string;
@@ -25,7 +23,6 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const {
now,
complete,
eventId,
normalColor,
warning,
warningColor,
@@ -37,7 +34,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '',
} = props;
const percentRemaining = 100 - useAnimatedProgress(now, complete, eventId);
const percentRemaining = 100 - getProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0;
@@ -1,3 +1,7 @@
.separator {
border-color: $border-color-ondark;
}
.backdrop {
position: fixed;
inset: 0;
@@ -19,15 +23,14 @@
width: 22rem;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
padding-block: 1rem;
background-color: $gray-1250;
color: $ui-white;
border-right: 1px solid $gray-1100;
box-shadow: $box-shadow-right;
&[data-open] {
transform: translateX(0%);
@@ -47,51 +50,22 @@
}
.header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 4rem;
padding: 0.75rem 1rem 0.75rem 1.25rem;
border-bottom: 1px solid $white-10;
}
height: 3.5rem;
padding-inline: 1.5rem;
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 1.25rem;
line-height: 1.2;
}
.clientName {
font-size: $aux-text-size;
color: $gray-600;
@include ellipsis-text;
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-block: 0.5rem 1rem;
}
.group {
display: flex;
flex-direction: column;
&:not(:first-child) {
margin-top: 0.75rem;
}
}
.groupLabel {
padding: 0.5rem 1.25rem 0.25rem;
color: $gray-700;
.note {
margin-left: auto;
color: $white-20;
}
@@ -1,7 +1,7 @@
import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
import { memo, PropsWithChildren } from 'react';
import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router';
@@ -14,11 +14,9 @@ import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import Eyebrow from '../eyebrow/Eyebrow';
import ClientLink from './client-link/ClientLink';
import EditorNavigation from './editor-navigation/EditorNavigation';
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
import OtherAddresses from './other-addresses/OtherAddresses';
import style from './NavigationMenu.module.scss';
@@ -35,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreenDocument();
const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation();
@@ -54,93 +52,75 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
<Dialog.Popup className={style.drawer}>
<div className={style.header}>
<div className={style.headerText}>
<Dialog.Title className={style.title}>Ontime</Dialog.Title>
{name && <span className={style.clientName}>{name}</span>}
</div>
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
<Dialog.Title>Ontime</Dialog.Title>
<IconButton variant='subtle-white' size='large' onClick={onClose}>
<IoClose />
</IconButton>
</div>
<div className={style.body}>
<MenuGroup label='This screen'>
{supportsFullscreen && (
<NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} />
)}
<NavigationMenuToggle
checked={mirror}
icon={<IoSwapVertical />}
label='Flip Screen'
onToggle={() => toggleMirror()}
/>
{canUseWakeLock && (
<NavigationMenuToggle
checked={keepAwake}
icon={<LuCoffee />}
label='Keep Awake'
onToggle={toggleKeepAwake}
/>
)}
<NavigationMenuItem onClick={handlers.open}>
<IoPencilOutline />
Rename Client
{supportsFullscreen && (
<NavigationMenuItem active={fullscreen} onClick={toggle}>
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
</MenuGroup>
)}
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
{keepAwake && <span className={style.note}>Active</span>}
</NavigationMenuItem>
)}
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
<MenuGroup label='Ontime app'>
<EditorNavigation />
<hr className={style.separator} />
<EditorNavigation />
<ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Cuesheet
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline />
Operator
</ClientLink>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Cuesheet
{route.label}
</ClientLink>
<ClientLink
to='op'
current={location.pathname === '/op'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Operator
</ClientLink>
</MenuGroup>
<MenuGroup label='Views'>
{navigatorConstants.map((route) => (
<ClientLink
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label}
</ClientLink>
))}
</MenuGroup>
))}
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div>
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
{isLocalhost && (
<div>
<OtherAddresses currentLocation={location.pathname} />
</div>
)}
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.group}>
<div className={style.groupLabel}>
<Eyebrow>{label}</Eyebrow>
</div>
{children}
</div>
);
}
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation();
const { data: urlPresets } = useUrlPresets();
@@ -149,7 +129,8 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
if (navPresets.length === 0) return null;
return (
<MenuGroup label='Presets'>
<>
<hr className={style.separator} />
{navPresets.map((preset) => (
<ClientLink
key={preset.alias}
@@ -160,6 +141,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
{preset.alias}
</ClientLink>
))}
</MenuGroup>
</>
);
}
@@ -1,8 +1,6 @@
import { useHotkeys } from '@mantine/hooks';
import { memo, useCallback, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react';
import { hasCustomParams } from '../../stores/savedViewParams';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation';
import NavigationMenu from './NavigationMenu';
@@ -15,37 +13,19 @@ interface ViewNavigationMenuProps {
suppressSettings?: boolean;
}
function isInteractiveKeyboardAction(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target.closest(
'button, a, input, textarea, select, [role="button"], [role="checkbox"], [role="switch"], [contenteditable]:not([contenteditable="false"])',
) !== null
);
}
export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams();
const hasSavedChanges = hasCustomParams(searchParams);
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
useHotkeys([
[
'Space',
(event) => {
if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
event.preventDefault();
toggleMenu();
() => {
if (isNavigationLocked) return;
menuHandler.toggle();
},
{ preventDefault: false },
{ preventDefault: true },
],
[
'mod + ,',
@@ -64,11 +44,10 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
return (
<>
<FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : toggleMenu}
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges}
/>
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />}
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
</>
);
}
@@ -1,29 +1,4 @@
.trailing {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.linkIcon {
margin-left: auto;
@include rotate-fourty-five;
}
.indicator {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
flex-shrink: 0;
}
.clear {
opacity: 0.6;
transition: opacity $transition-time-action;
&:hover,
&:focus-visible {
opacity: 1;
}
}
@@ -1,17 +1,9 @@
import { MouseEvent, PropsWithChildren } from 'react';
import { IoArrowUp, IoCloseOutline } from 'react-icons/io5';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
import { PropsWithChildren } from 'react';
import { IoArrowUp } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import { useElectronEvent } from '../../../hooks/useElectronEvent';
import {
hasCustomParams,
reservedParams,
stripReservedParams,
useSavedViewParams,
} from '../../../stores/savedViewParams';
import { handleLinks } from '../../../utils/linkUtils';
import IconButton from '../../buttons/IconButton';
import Tooltip from '../../tooltip/Tooltip';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
import style from './ClientLink.module.scss';
@@ -24,104 +16,32 @@ interface ClientLinkProps {
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const { isElectron } = useElectronEvent();
const navigate = useNavigate();
if (isElectron) {
return (
<ElectronNavigationItem current={current} to={to} postAction={postAction}>
<NavigationMenuItem
active={current}
onClick={() => {
handleLinks(to);
postAction?.();
}}
>
{children}
</ElectronNavigationItem>
<IoArrowUp className={style.linkIcon} />
</NavigationMenuItem>
);
}
return (
<BrowserNavigationItem current={current} to={to} postAction={postAction}>
<NavigationMenuItem
active={current}
onClick={() => {
navigate(`/${to}`);
postAction?.();
}}
>
{children}
</BrowserNavigationItem>
);
}
function ElectronNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigateToLink = () => {
handleLinks(to);
postAction?.();
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
<span className={style.trailing}>
<IoArrowUp className={style.linkIcon} />
</span>
</NavigationMenuItem>
);
}
function BrowserNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { params: savedParams, save, clear } = useSavedViewParams();
/**
* Save the params of the view we are leaving and resolve the destination,
* restoring any params previously saved for the target view.
*/
const resolveDestination = () => {
const currentView = location.pathname.replace(/^\//, '');
save(currentView, stripReservedParams(location.search));
const restored = savedParams[to];
return `${to}${restored ? `?${restored}` : ''}`;
};
const isCustomised = current ? hasCustomParams(searchParams) : Boolean(savedParams[to]);
const navigateToLink = () => {
const destination = resolveDestination();
navigate(`/${destination}`);
postAction?.();
};
/**
* Clear the saved settings for this route without navigating to it.
* When it is the current view, also reset the live URL to its defaults.
*/
const clearViewSettings = (event: MouseEvent) => {
event.stopPropagation();
clear(to);
// if mounted, clear the URL params
if (current) {
const preserved = new URLSearchParams();
reservedParams.forEach((key) => {
const value = searchParams.get(key);
if (value !== null) preserved.set(key, value);
});
setSearchParams(preserved);
}
};
return (
<NavigationMenuItem active={current} onClick={navigateToLink}>
{children}
{isCustomised && (
<span className={style.trailing}>
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
<Tooltip
text='Reset to default'
render={
<IconButton
variant='ghosted-white'
size='small'
className={style.clear}
aria-label='Reset to default'
onClick={clearViewSettings}
/>
}
>
<IoCloseOutline />
</Tooltip>
</span>
)}
</NavigationMenuItem>
);
}
@@ -22,19 +22,3 @@
top: 0;
z-index: $zindex-nav;
}
.buttonWithIndicator {
position: relative;
display: inline-flex;
}
.indicator {
position: absolute;
top: -0.15rem;
right: -0.15rem;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background-color: $active-indicator;
pointer-events: none;
}
@@ -9,10 +9,9 @@ import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps {
toggleMenu?: () => void;
toggleSettings?: () => void;
hasSavedChanges?: boolean;
}
export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSavedChanges }: FloatingNavigationProps) {
export default function FloatingNavigation({ toggleMenu, toggleSettings }: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity(true);
return (
@@ -21,18 +20,15 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSave
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
>
{toggleMenu && (
<div className={style.buttonWithIndicator}>
<IconButton
variant='subtle-white'
size='xlarge'
onClick={toggleMenu}
aria-label='toggle menu'
data-testid='navigation__toggle-menu'
>
<IoApps />
</IconButton>
{hasSavedChanges && <span className={style.indicator} data-testid='navigation__saved-indicator' />}
</div>
<IconButton
variant='subtle-white'
size='xlarge'
onClick={toggleMenu}
aria-label='toggle menu'
data-testid='navigation__toggle-menu'
>
<IoApps />
</IconButton>
)}
{toggleSettings && (
<IconButton
@@ -2,68 +2,35 @@
display: flex;
align-items: center;
padding: 0.625rem 1.25rem;
gap: 0.625rem;
padding: 0.75rem 1.5rem;
gap: 0.5rem;
width: 100%;
border-left: 3px solid transparent;
color: $gray-200;
font-size: $text-body-size;
border-left: 4px solid transparent;
color: $action-text-color;
white-space: nowrap;
text-align: left;
cursor: pointer;
transition-property: color, background-color;
transition-property: color;
transition-duration: $transition-time-action;
// keep icons on a fixed box so labels align regardless of the glyph
> svg {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
color: $gray-500;
transition: color $transition-time-action;
}
&:hover {
background-color: $white-3;
color: $ui-white;
> svg {
color: $gray-300;
}
color: $ontime-color;
background-color: $gray-1350;
}
&:active {
background-color: $white-7;
background-color: $border-color-ondark;
}
&:focus-visible {
&:focus {
outline: 2px solid $blue-500;
outline-offset: -2px;
background-color: $white-3;
}
// toggle rows are labels, the focusable control sits inside them
&:focus-within {
background-color: $white-3;
background-color: $gray-1350;
}
&.current {
background-color: $bg-active;
border-left-color: $blue-400;
color: $ui-white;
font-weight: 600;
> svg {
color: $blue-300;
}
background-color: $gray-1300;
border-left: 4px solid $action-text-color;
}
}
.label {
flex: 1;
min-width: 0;
@include ellipsis-text;
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react';
import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent';
import { isKeyEnter } from '../../../utils/keyEvent';
import { cx } from '../../../utils/styleUtils';
import style from './NavigationMenuItem.module.scss';
@@ -11,7 +11,6 @@ interface NavigationMenuItemProps {
onClick: () => void;
}
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
export default function NavigationMenuItem({
active,
className,
@@ -25,9 +24,7 @@ export default function NavigationMenuItem({
role='button'
onClick={onClick}
onKeyDown={(event) => {
if (isKeyEnter(event) || isKeySpace(event)) {
event.preventDefault();
event.stopPropagation();
if (isKeyEnter(event)) {
onClick();
}
}}
@@ -1,23 +0,0 @@
import { ReactNode } from 'react';
import Switch from '../../switch/Switch';
import style from './NavigationMenuItem.module.scss';
interface NavigationMenuToggleProps {
checked: boolean;
icon: ReactNode;
label: string;
onToggle: () => void;
}
/** A menu row which reflects, and toggles, an on/off state */
export default function NavigationMenuToggle({ checked, icon, label, onToggle }: NavigationMenuToggleProps) {
return (
<label className={style.link}>
{icon}
<span className={style.label}>{label}</span>
<Switch checked={checked} onCheckedChange={onToggle} />
</label>
);
}
@@ -1,52 +1,22 @@
.footer {
flex: 0 0 auto;
padding: 0.75rem 0;
border-top: 1px solid $white-10;
}
.header {
padding: 0 1.25rem 0.25rem;
font-size: calc(1rem - 2px);
margin-left: 1rem;
color: $gray-700;
}
.interfaces {
padding: 0.25rem 1.25rem 0;
padding: 0.5rem 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.interface {
min-width: 0;
max-width: 100%;
> div {
max-width: 100%;
> button:first-child {
min-width: 0;
max-width: calc(100% - 1.5rem);
justify-content: flex-start;
padding-inline: 0.5rem;
}
}
gap: 0.5rem;
}
.interfaceCopy {
display: flex;
align-items: center;
min-width: 0;
width: 100%;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.goIcon {
flex: 0 0 auto;
@include rotate-fourty-five;
margin-left: 0.25rem;
margin-bottom: 0.25rem;
@@ -3,8 +3,6 @@ import { IoArrowUp } from 'react-icons/io5';
import useInfo from '../../../hooks-query/useInfo';
import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
import CopyTag from '../../copy-tag/CopyTag';
import Eyebrow from '../../eyebrow/Eyebrow';
import { getExternalInterfaces } from './otherAddresses.utils';
import style from './OtherAddresses.module.scss';
@@ -14,33 +12,32 @@ interface OtherAddressesProps {
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
const { data } = useInfo();
const externalInterfaces = getExternalInterfaces(data.networkInterfaces);
if (externalInterfaces.length === 0) {
// there is no point showing this if we only have one interface
if (data.networkInterfaces.length < 2) {
return null;
}
return (
<div className={style.footer}>
<div className={style.header}>
<Eyebrow>Also available at</Eyebrow>
</div>
<>
<div className={style.header}>Accessible on external networks</div>
<div className={style.interfaces}>
{externalInterfaces.map((nif) => {
{data?.networkInterfaces?.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation);
return (
<div key={nif.name} className={style.interface}>
<CopyTag copyValue={address} onClick={() => openLink(address)} size='small'>
<span className={style.interfaceCopy}>
<span className={style.address}>{nif.address}</span>
<IoArrowUp className={style.goIcon} />
</span>
</CopyTag>
</div>
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
<span className={style.interfaceCopy}>
{nif.address} <IoArrowUp className={style.goIcon} />
</span>
</CopyTag>
);
})}
</div>
</div>
</>
);
}
@@ -1,31 +0,0 @@
import { describe, expect, test } from 'vitest';
import { getExternalInterfaces } from '../otherAddresses.utils';
describe('getExternalInterfaces', () => {
test('returns no alternatives when localhost is the only interface', () => {
expect(getExternalInterfaces([{ name: 'localhost', address: '127.0.0.1' }])).toEqual([]);
});
test('does not treat an interface name as deployment information', () => {
expect(
getExternalInterfaces([
{ name: 'localhost', address: '127.0.0.1' },
{ name: 'cloud', address: '192.168.1.42' },
]),
).toEqual([{ name: 'cloud', address: '192.168.1.42' }]);
});
test('returns non-local interfaces when local and external interfaces are available', () => {
expect(
getExternalInterfaces([
{ name: 'localhost', address: '127.0.0.1' },
{ name: 'Wi-Fi', address: '192.168.1.42' },
{ name: 'Ethernet', address: '10.0.0.42' },
]),
).toEqual([
{ name: 'Wi-Fi', address: '192.168.1.42' },
{ name: 'Ethernet', address: '10.0.0.42' },
]);
});
});
@@ -1,6 +0,0 @@
import { type NetworkInterface } from 'ontime-types';
/** Returns network addresses other than the localhost address injected by the info endpoint. */
export function getExternalInterfaces(interfaces: NetworkInterface[]) {
return interfaces.filter((networkInterface) => networkInterface.name !== 'localhost');
}
@@ -14,4 +14,6 @@ $progress-bar-br: 3px;
.progress-bar__indicator {
height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear;
transition-property: width;
}
@@ -1,18 +1,18 @@
import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import { getProgress } from '../../utils/getProgress';
import './ProgressBar.scss';
interface ProgressBarProps {
current: MaybeNumber;
duration: MaybeNumber;
eventId?: string | null;
className?: string;
}
export default function ProgressBar({ current, duration, eventId, className }: ProgressBarProps) {
const progress = useAnimatedProgress(current, duration, eventId);
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, className } = props;
const progress = getProgress(current, duration);
return (
<div className={`progress-bar__bg ${className}`}>
@@ -1,43 +1,18 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer {
width: 100%;
text-align: center;
color: var(--secondary-color-override, $viewer-secondary-color);
color: $white-10;
.empty {
display: block;
width: min(100%, 14rem);
margin: 0 auto -1.5rem;
opacity: 0.6;
width: 100%;
opacity: 0.8;
}
.text {
display: block;
margin-inline: auto;
font-weight: 400;
font-size: clamp(1rem, 1.55vw, 1.5rem);
line-height: 1.35;
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;
font-weight: 600;
font-size: 2em;
max-width: 600px;
}
}
@@ -1,5 +1,4 @@
import { CSSProperties } from 'react';
import { IoWarningOutline } from 'react-icons/io5';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
@@ -10,18 +9,12 @@ interface EmptyProps {
text?: string;
injectedStyles?: CSSProperties;
className?: string;
variant?: 'error';
}
export default function Empty({ text, className, injectedStyles, variant }: EmptyProps) {
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
return (
<div
className={cx([style.emptyContainer, variant === 'error' && style.error, className])}
style={injectedStyles}
role={variant === 'error' ? 'alert' : undefined}
>
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
<EmptyImage className={style.empty} />
{variant === 'error' && <IoWarningOutline className={style.errorIcon} aria-hidden />}
{text && <span className={style.text}>{text}</span>}
</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 */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100dvh;
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
@@ -16,6 +16,5 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-block: 5rem;
padding-top: 5rem;
}
@@ -7,13 +7,12 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
injectedStyles?: CSSProperties;
variant?: 'error';
}
export default function EmptyPage({ text, injectedStyles, variant }: EmptyPageProps) {
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
return (
<div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} variant={variant} />
<Empty text={text} injectedStyles={injectedStyles} />
</div>
);
}
@@ -15,4 +15,9 @@
gap: 1rem;
margin-top: 1em;
}
.text {
font-weight: 600;
font-size: 2em;
}
}
@@ -18,7 +18,8 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
<Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
{handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
@@ -11,11 +11,6 @@
color: $ui-white;
}
.active {
border: 1px solid $green-700;
color: $green-400;
}
.warning {
background-color: $orange-1300;
color: $orange-300;
@@ -6,7 +6,7 @@ import style from './Tag.module.scss';
interface TagProps {
className?: string;
variant?: 'default' | 'active' | 'warning';
variant?: 'default' | 'warning';
}
export default function Tag({ className, variant = 'default', children }: PropsWithChildren<TagProps>) {
@@ -2,12 +2,6 @@
display: flex;
align-items: center;
gap: 0.5rem;
> span {
font-variant-numeric: tabular-nums;
text-align: right;
text-transform: uppercase;
}
}
// attempt to match with ontimeTextInputs
@@ -1,77 +1,37 @@
.multiOptionList {
overflow-y: auto;
max-height: 12rem;
background: $gray-1200;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
.inline {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
// styles from subtle button
.toggleSelect {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 2.5rem;
padding: 0.5rem 0.75rem;
background: transparent;
border: 0;
color: $gray-300;
gap: 0.25rem;
padding-inline: 0.5rem;
height: 2.5rem;
background: $gray-1050;
color: $ui-white;
line-height: 1em;
cursor: pointer;
transition:
background-color $transition-time-action,
border-color $transition-time-action,
color $transition-time-action;
border-radius: $component-border-radius-md;
&:hover {
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
color: $blue-500;
}
&:active {
&:active:not(:disabled) {
background: $gray-1100;
}
&:focus-within {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
&.selected {
background: $bg-active;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
}
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
> [role='checkbox'] {
flex: 0 0 auto;
width: 1.125rem;
height: 1.125rem;
border: 1px solid $gray-400;
&[data-unchecked] {
background: $gray-1100;
}
&[data-checked] {
border-color: $blue-400;
}
&:focus-visible {
outline: 0;
}
border-color: $gray-1250;
}
&::after {
content: '';
flex: 0 0 auto;
margin-left: auto;
width: 0.625rem;
height: 0.625rem;
margin-left: 0.25rem;
width: 0.75em;
height: 0.75em;
background: var(--user-bg, $gray-900);
border-radius: 50%;
}
@@ -2,7 +2,6 @@ import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../views/common/viewUtils';
import { cx } from '../../utils/styleUtils';
import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select';
@@ -108,20 +107,18 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
return (
<>
<input name={id} hidden readOnly value={paramState.join(',')} />
<div className={style.multiOptionList}>
<div className={style.inline}>
{values.map((option) => {
const selected = paramState.includes(option.value);
return (
<label
key={option.value}
className={cx([style.toggleSelect, selected && style.selected])}
className={style.toggleSelect}
style={{
'--user-bg': option.colour,
}}
>
<Checkbox
checked={selected}
checked={paramState.includes(option.value)}
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
/>
{option.label}
@@ -1,3 +1,13 @@
.drawerFooter {
display: flex;
justify-content: end;
gap: $section-spacing;
button {
padding: 0 2em;
}
}
.backdrop {
position: fixed;
inset: 0;
@@ -24,11 +34,11 @@
display: flex;
flex-direction: column;
padding-block: 1rem 1.5rem;
background-color: $ui-black;
background-color: $gray-1250;
color: $ui-white;
border-left: 1px solid $gray-1100;
box-shadow: $box-shadow-l1;
&[data-open] {
transform: translateX(0%);
@@ -48,54 +58,36 @@
}
.header {
flex: 0 0 auto;
padding-inline: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 4rem;
padding: 0.75rem 1rem;
}
height: 3.5rem;
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 1.25rem;
line-height: 1.2;
}
.target {
font-size: $aux-text-size;
color: $gray-600;
}
.body {
flex: 1;
padding: 1rem;
padding-inline: 1rem;
padding-bottom: 10vh;
overflow-y: auto;
}
.footer {
flex: 0 0 auto;
display: flex;
padding: 0.75rem 1rem;
padding-inline: 1rem;
gap: 1rem;
align-items: center;
justify-content: flex-end;
background-color: $ui-black;
border-top: 1px solid $white-10;
}
.sectionList {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 2rem;
padding-right: 0.5rem;
}
.info {
@@ -4,10 +4,8 @@ import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { viewLabels } from '../../../viewerConfig';
import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
import Info from '../info/Info';
@@ -29,7 +27,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
const clearSavedParams = useSavedViewParams((store) => store.clear);
const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
@@ -39,7 +36,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
};
const resetParams = () => {
clearSavedParams(target);
setSearchParams(getPreservedParams());
};
@@ -72,17 +68,8 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
<Dialog.Backdrop className={style.backdrop} />
<Dialog.Popup className={style.drawer}>
<div className={style.header}>
<div className={style.headerText}>
<Dialog.Title className={style.title}>Customise</Dialog.Title>
<span className={style.target}>{viewLabels[target]}</span>
</div>
<IconButton
variant='subtle-white'
size='large'
aria-label='Close'
data-testid='close-view-params'
onClick={handleClose}
>
<Dialog.Title>Customise</Dialog.Title>
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
<IoClose />
</IconButton>
</div>
@@ -1,21 +1,10 @@
.presetSection {
background-color: $gray-1350;
border: 1px solid $white-10;
border-radius: $component-border-radius-md;
margin-bottom: 0.75rem;
}
.header {
padding: 0.5rem 1rem;
min-height: 2.25rem;
display: flex;
align-items: center;
color: $gray-300;
}
.presetList {
border-top: 1px solid $white-7;
flex-direction: column;
gap: 0.25rem;
padding: 1rem 0.5rem;
margin-bottom: 1rem;
max-height: 10rem;
overflow-y: auto;
scrollbar-gutter: stable;
@@ -25,39 +14,12 @@
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 2.5rem;
padding: 0.375rem 1rem;
font-size: $inner-section-text-size;
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
&.active {
background-color: $bg-active;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
font-weight: 600;
color: $blue-500;
}
}
.presetName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presetActions {
margin-left: auto;
}
.current {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
color: $blue-400;
font-size: $aux-text-size;
font-weight: 600;
}
@@ -4,7 +4,6 @@ import { useSearchParams } from 'react-router';
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button';
import Eyebrow from '../eyebrow/Eyebrow';
import style from './ViewParamsPresets.module.scss';
@@ -27,31 +26,22 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
return (
<div className={style.presetSection}>
<div className={style.header}>
<Eyebrow>Saved presets</Eyebrow>
</div>
<div className={style.presetList}>
{viewPresets.map((preset) => {
const active = searchParams.get('alias') === preset.alias;
return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
<span className={style.presetName}>{preset.alias}</span>
{active ? (
<span className={style.current}>Current</span>
) : (
<Button
variant='ghosted'
size='small'
onClick={() => handleRecall(preset)}
className={style.presetActions}
>
Apply
</Button>
)}
</div>
);
})}
</div>
{viewPresets.map((preset) => {
const active = searchParams.get('alias') === preset.alias;
return (
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
<div>{preset.alias}</div>
<Button
variant={active ? 'ghosted' : 'subtle-white'}
onClick={() => handleRecall(preset)}
disabled={active}
className={style.presetActions}
>
{active ? 'Applied' : 'Apply'}
</Button>
</div>
);
})}
</div>
);
}
@@ -1,118 +1,48 @@
.section {
overflow: hidden;
color: $ui-white;
font-size: 1rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 1rem;
background: transparent;
border: 0;
font: inherit;
text-align: left;
color: $gray-300;
height: 2rem;
&.collapsible {
cursor: pointer;
transition-property: color, background-color;
transition-duration: $transition-time-action;
&:hover {
background-color: $white-3;
color: $ui-white;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
}
}
.sectionTitle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/** marks a section which holds values coming from the URL */
.customised {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background-color: $active-indicator;
flex-shrink: 0;
}
.chevron {
flex-shrink: 0;
rotate: 180deg;
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&.closed {
rotate: 0deg;
}
}
.options {
padding-inline: 1rem;
border-top: 1px solid $white-7;
}
.label {
display: grid;
grid-template-columns: minmax(0, 1fr);
row-gap: 0.25rem;
padding: 0.75rem 0;
margin-top: $section-spacing;
font-size: $inner-section-text-size;
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
}
/**
* Compact controls sit beside their label instead of below it.
* Placement is done with grid areas so that the markup stays flat.
*/
.inline {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
column-gap: 1rem;
> .title {
grid-area: 1 / 1;
}
> .description {
grid-area: 2 / 1;
}
> *:last-child {
grid-area: 1 / 2 / span 2;
}
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title {
color: $ui-white;
font-size: $inner-section-text-size;
display: block;
width: 100%;
}
.description {
color: $gray-400;
font-size: $aux-text-size;
line-height: 1.4;
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
}
.closed {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 0deg;
}
.open {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 180deg;
}
.hidden {
@@ -1,9 +1,7 @@
import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { cx } from '../../utils/styleUtils';
import Eyebrow from '../eyebrow/Eyebrow';
import { OptionTitle } from './constants';
import ParamInput from './ParamInput';
import { type ParamField } from './viewParams.types';
@@ -16,74 +14,50 @@ interface ViewParamsSectionProps {
options: ParamField[];
}
/** Fields which are compact enough to share a row with their label */
function isInlineField(option: ParamField): boolean {
return option.type === 'boolean' || option.type === 'colour';
}
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const [searchParams] = useSearchParams();
// hidden options are inputs without a UI, there is nothing to frame them with
if (title === OptionTitle.Hidden) {
return <HiddenContents options={options} />;
}
const isCollapsed = Boolean(collapsible && collapsed);
const handleCollapse = () => {
if (collapsible) {
setCollapsed((prev) => !prev);
}
};
return (
<section className={style.section}>
<SectionHeader
title={title}
isCustomised={options.some((option) => searchParams.has(option.id))}
collapsed={isCollapsed}
onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined}
/>
{/* collapsed options stay mounted: the form reads its values from the DOM */}
<div className={cx([style.options, isCollapsed && style.hidden])}>
{options.map((option) => (
<label key={option.title} className={cx([style.label, isInlineField(option) && style.inline])}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
))}
</div>
{title === OptionTitle.Hidden ? (
<HiddenContents options={options} />
) : (
<>
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
{title}
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
</div>
<SectionContents options={options} collapsed={collapsed} />
</>
)}
</section>
);
}
interface SectionHeaderProps {
title: string;
isCustomised: boolean;
interface SectionContentsProps {
options: ParamField[];
collapsed: boolean;
/** a header without a toggle belongs to a section which cannot collapse */
onToggle?: () => void;
}
function SectionHeader({ title, isCustomised, collapsed, onToggle }: SectionHeaderProps) {
const label = (
<Eyebrow className={style.sectionTitle}>
{title}
{isCustomised && <span className={style.customised} title='Contains custom values' />}
</Eyebrow>
);
if (!onToggle) {
return <div className={style.sectionHeader}>{label}</div>;
}
function SectionContents({ options, collapsed }: SectionContentsProps) {
return (
<button
type='button'
className={cx([style.sectionHeader, style.collapsible])}
aria-expanded={!collapsed}
onClick={onToggle}
>
{label}
<IoChevronDown className={cx([style.chevron, collapsed && style.closed])} />
</button>
<>
{options.map((option) => {
return (
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
);
})}
</>
);
}
@@ -1,49 +0,0 @@
import { EntryId, MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress';
import { useIsOnline, usePlayback } from './useSocket';
/**
* Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber, eventId?: EntryId | null): number {
const playback = usePlayback();
const isOnline = useIsOnline();
const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, duration, eventId, playback, at: performance.now() });
const [, setTick] = useState(0);
const now = performance.now();
const hasAuthoritativeUpdate =
baseline.current.current !== current || // handle timer updates
baseline.current.duration !== duration || // handle duration changes
baseline.current.eventId !== eventId || // handle event changing
baseline.current.playback !== playback; // handle playback changes
if (hasAuthoritativeUpdate) {
// Reset during render so an event change is reflected in this very paint.
baseline.current = { current, duration, eventId, playback, at: now };
}
// 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
useEffect(() => {
if (!shouldAnimate) {
return;
}
let frame = requestAnimationFrame(function tick() {
setTick((value) => value + 1);
frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [shouldAnimate]);
// Derive from the anchor plus elapsed time at render; freeze while disconnected or not running.
const anchored = baseline.current.current;
const value = isOnline && isRunning && anchored !== null ? anchored - (now - baseline.current.at) : anchored;
return getProgress(value, duration);
}
+8 -37
View File
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
InsertOptions,
MaybeString,
OntimeDelay,
OntimeEntry,
@@ -49,7 +50,6 @@ import {
requestEventSwap,
requestGroupEntries,
requestUngroup,
requestFitGroupTarget,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
@@ -60,15 +60,9 @@ export type EventOptions = Partial<{
before: MaybeString;
// options of entries of type OntimeEvent
linkPrevious: boolean;
/** the timing reference for the new entry, usually the same as after */
lastEventId: MaybeString;
}>;
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/**
* Gather utilities for actions on entries in the loaded rundown.
*/
@@ -126,7 +120,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* @private
*/
const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: [string, PatchWithId & ClientInsertOptions]) => postAddEntry(rundownId, entry),
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => {
const queryKey = resolveCurrentRundownQueryKey();
await queryClient.cancelQueries({ queryKey });
@@ -155,9 +149,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
addToRundown(
newRundown,
optimisticEntry,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
afterId,
entry.before ?? null,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
);
queryClient.setQueryData<Rundown>(queryKey, newRundown);
@@ -200,7 +193,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
throw new Error('Rundown not initialised');
}
const newEntry: PatchWithId & ClientInsertOptions = { ...entry, id: generateId() };
const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types
if (options?.after) {
@@ -212,8 +205,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
// last event id is the timing reference of the previous OntimeEvent in rundown
if (options?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const previousEvent = rundownData.entries[options?.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd;
@@ -291,7 +284,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* Clone an entry
*/
const clone = useCallback(
async (entryId: EntryId, options?: ClientInsertOptions) => {
async (entryId: EntryId, options?: InsertOptions) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
@@ -467,27 +460,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
return previousEnd;
}
},
[getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey],
);
/**
* 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],
[getCurrentRundownData, updateEntryMutation, queryClient],
);
/**
@@ -1030,7 +1003,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents,
updateEntry,
updateTimer,
matchGroupDuration,
}),
[
addEntry,
@@ -1048,7 +1020,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
swapEvents,
updateEntry,
updateTimer,
matchGroupDuration,
],
);
}
@@ -1086,7 +1057,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
/**
* Utility to create an optimistic entry for immediate cache insertion
*/
function createOptimisticEntry(payload: PatchWithId & ClientInsertOptions): OntimeEntry {
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id;
let parent: EntryId | null = null;
@@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types';
import { RefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef | null>,
scrollRef: RefObject<ScrollRef | null>,
componentRef: RefObject<ComponentRef>,
scrollRef: RefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
@@ -21,16 +21,18 @@ interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
followTrigger: MaybeString; // this would be an entry id or null
getTopOffset: () => number;
topOffset?: number;
setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
}
export default function useFollowComponent({
followRef,
scrollRef,
doFollow,
topOffset = 100,
setScrollFlag,
followTrigger,
getTopOffset,
}: UseFollowComponentProps) {
// when trigger moves, view should follow
useEffect(() => {
@@ -39,17 +41,25 @@ export default function useFollowComponent({
}
if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
// resolve the offset after layout, so that measured values are up to date
scrollToComponent(followRef, scrollRef, getTopOffset());
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
setScrollFlag?.(false);
});
}
}, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]);
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
const scrollToRefComponent = useCallback(() => {
scrollToComponent(followRef, scrollRef, getTopOffset());
}, [followRef, scrollRef, getTopOffset]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
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;
}
-12
View File
@@ -139,17 +139,6 @@ export const setEventPlayback = {
pause: () => sendSocket('pause', undefined),
};
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
phase: state.timer.phase,
addedTime: state.timer.addedTime,
secondaryTimer: state.timer.secondaryTimer,
current: state.timer.current,
expectedFinish: state.timer.expectedFinish,
startedAt: state.timer.startedAt,
isCountToEnd: state.eventNow?.countToEnd ?? false,
}));
export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
@@ -163,7 +152,6 @@ export const useNextFlag = createSelector((state: RuntimeStore) => ({
export const useProgressData = createSelector((state: RuntimeStore) => ({
current: state.timer.current,
duration: state.timer.duration,
eventId: state.eventNow?.id ?? null,
timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null,
}));
@@ -1,87 +0,0 @@
import { hasCustomParams, stripReservedParams, useSavedViewParams } from '../savedViewParams';
describe('savedViewParams store', () => {
beforeEach(() => {
useSavedViewParams.getState().clearAll();
});
it('saves and restores params per view', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=true');
expect(useSavedViewParams.getState().params.backstage).toBe('showProgress=false');
});
it('overwrites the saved params for a view on subsequent saves', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', 'hideSeconds=false');
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=false');
});
it('does not store an entry for an empty search string', () => {
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('removes a previously saved entry when saved with an empty search string', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('timer', '');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('ignores empty view keys', () => {
useSavedViewParams.getState().save('', 'hideSeconds=true');
expect(useSavedViewParams.getState().params).toEqual({});
});
it('clears the saved params for a single view without touching others', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('is a no-op when clearing a view without saved params', () => {
useSavedViewParams.getState().save('backstage', 'showProgress=false');
useSavedViewParams.getState().clear('timer');
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
});
it('clears all saved params', () => {
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
useSavedViewParams.getState().clearAll();
expect(useSavedViewParams.getState().params).toEqual({});
});
});
describe('stripReservedParams', () => {
it('removes reserved auth/preset params while keeping view customisation', () => {
expect(stripReservedParams('hideSeconds=true&token=abc&n=1&alias=my')).toBe('hideSeconds=true');
});
it('returns an empty string when only reserved params are present', () => {
expect(stripReservedParams('token=abc&n=1&alias=my')).toBe('');
});
});
describe('hasCustomParams', () => {
it('is true when a non-reserved param is present', () => {
expect(hasCustomParams(new URLSearchParams('hideSeconds=true&token=abc'))).toBe(true);
});
it('is false when only reserved params are present', () => {
expect(hasCustomParams(new URLSearchParams('token=abc&n=1&alias=my'))).toBe(false);
});
it('is false when there are no params', () => {
expect(hasCustomParams(new URLSearchParams(''))).toBe(false);
});
});
@@ -1,62 +0,0 @@
import { create } from 'zustand';
// params that are auth/preset markers, not user view customisation (see common/utils/urlPresets.ts)
export const reservedParams = new Set(['token', 'n', 'alias']);
interface SavedViewParamsStore {
params: Record<string, string>; // view key (e.g. "timer") -> search string without leading "?"
save: (view: string, search: string) => void;
clear: (view: string) => void;
clearAll: () => void;
}
/**
* Remembers the last view parameters used for each view so they can be
* restored when the user navigates back to that view through the menu.
* In-memory only: persists across SPA navigation, resets on a full page reload.
*/
export const useSavedViewParams = create<SavedViewParamsStore>((set) => ({
params: {},
save: (view, search) =>
set((state) => {
// ignore empty view keys and do not store empty entries, so the
// "saved changes" indicator only reflects genuine customisation
if (!view) return state;
const params = { ...state.params };
if (search) {
params[view] = search;
} else {
delete params[view];
}
return { params };
}),
clear: (view) =>
set((state) => {
if (!state.params[view]) return state;
const params = { ...state.params };
delete params[view];
return { params };
}),
clearAll: () => set({ params: {} }),
}));
/**
* Removes reserved (auth/preset) params from a search string, keeping only
* genuine view customisation.
*/
export function stripReservedParams(search: string): string {
const sp = new URLSearchParams(search);
reservedParams.forEach((key) => sp.delete(key));
return sp.toString();
}
/**
* Whether the search params contain any genuine view customisation,
* ignoring reserved params.
*/
export function hasCustomParams(searchParams: URLSearchParams): boolean {
for (const key of searchParams.keys()) {
if (!reservedParams.has(key)) return true;
}
return false;
}
@@ -1,4 +1,4 @@
import { hostToBaseUrl, linkToOtherHost } from '../linkUtils';
import { linkToOtherHost } from '../linkUtils';
describe('linkToOTherHost', () => {
it('should handle electron links', () => {
@@ -22,18 +22,3 @@ describe('linkToOTherHost', () => {
expect(destination).toBe('https://app.getontime.no/user-hash/path');
});
});
describe('hostToBaseUrl', () => {
it('uses http when the page is plain http', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'http://localhost:4001')).toBe('http://192.168.10.166:4001');
});
it('keeps https when the page reaches the server over TLS on the same port', () => {
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://localhost:4001')).toBe('https://192.168.10.166:4001');
});
it('falls back to http when the page is https on a different port (TLS proxy)', () => {
// the raw server port does not speak TLS, an https link to it would fail
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://ontime.example.com')).toBe('http://192.168.10.166:4001');
});
});
@@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest';
import { removeFileExtension } from '../uploadUtils';
describe('removeFileExtension()', () => {
it('removes a trailing extension', () => {
expect(removeFileExtension('show.xlsx')).toBe('show');
});
it('only removes the last extension', () => {
expect(removeFileExtension('my.show.xlsx')).toBe('my.show');
});
it('returns the name unchanged when there is no extension', () => {
expect(removeFileExtension('rundown')).toBe('rundown');
});
it('does not treat a leading dot as an extension', () => {
expect(removeFileExtension('.gitignore')).toBe('.gitignore');
});
it('handles an empty string', () => {
expect(removeFileExtension('')).toBe('');
});
});
-4
View File
@@ -4,10 +4,6 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Enter';
}
export function isKeySpace<T>(event: KeyboardEvent<T>): boolean {
return event.key === ' ' || event.code === 'Space';
}
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape';
}
+1 -20
View File
@@ -37,12 +37,7 @@ export function handleLinks(
event?.preventDefault();
const destination = new URL(externalServerUrl);
// split off any query string so it is not encoded into the pathname
const [pathname, search] = location.split('?');
destination.pathname = externalBaseURI ? `${externalBaseURI}/${pathname}` : pathname;
if (search) {
destination.search = search;
}
destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
openLink(destination.toString());
}
@@ -59,17 +54,3 @@ export function linkToOtherHost(
}
return destination.toString();
}
/**
* Builds a base URL for reaching the Ontime server on a given network interface.
* The Ontime server itself only speaks HTTP, so we only keep https when the page
* already reaches the server over TLS on that same port (eg. a proxy in front of it);
* any other explicit server port gets http.
* externalServerUrl is used for testing
*/
export function hostToBaseUrl(host: string, port: number, externalServerUrl: string = serverURL): string {
const page = new URL(externalServerUrl);
const pagePort = page.port === '' ? (page.protocol === 'https:' ? 443 : 80) : Number(page.port);
const scheme = page.protocol === 'https:' && pagePort === port ? 'https' : 'http';
return `${scheme}://${host}:${port}`;
}
+4 -9
View File
@@ -3,9 +3,7 @@ import {
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
dayInMs,
formatFromMillis,
getExpectedEnd,
getExpectedStart,
} from 'ontime-utils';
@@ -31,11 +29,6 @@ export function nowInMillis(): number {
return elapsed;
}
export function normaliseWallClock(time: number): number {
const timeOfDay = time % dayInMs;
return timeOfDay < 0 ? timeOfDay + dayInMs : timeOfDay;
}
/**
* @description Resolves format from url and store
* @return {string|null} A format string like "hh:mm:ss a" or null
@@ -187,13 +180,15 @@ export function getExpectedTimesFromExtendedEvent(
...state,
},
);
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
const plannedEnd = event.timeStart + event.duration + event.delay;
return {
expectedStart,
timeToStart: expectedStart - state.clock,
expectedEnd,
expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
plannedEnd,
};
}
@@ -38,18 +38,6 @@ export function validateProjectFile(file: File) {
}
}
/**
* Removes a trailing file extension from a file name (e.g. "show.xlsx" -> "show")
* A leading dot (dotfiles like ".gitignore") is not treated as an extension
*/
export function removeFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0) {
return fileName;
}
return fileName.slice(0, lastDot);
}
export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx');
}
+35
View File
@@ -1,3 +1,5 @@
import { AppMode } from '../ontimeConfig';
declare module '*.scss' {
const content: Record<string, string>;
export default content;
@@ -30,6 +32,39 @@ declare global {
}
}
/**
* Declare custom data we pass to the table
* - `handleUpdate` callback to update the entry when the user edits a cell
* - `handleUpdateTimer` callback to update the timer for a specific event
* - `options-showDelayedTimes` whether to show or hide delayed times
* - `options-hideTableSeconds` whether to hide seconds in the table
* - `options-hideIndexColumn` whether to hide the index column
* - `options-cuesheetMode` run or edit mode
*
* And metadata specific for each column
* - `canWrite` whether the user can write to this column
* - `colour` background colour associated with a custom field
*/
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData extends RowData> {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
hideIndexColumn: boolean;
cuesheetMode: AppMode;
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
canWrite: boolean;
colour?: string;
}
}
/**
* Allow passing CSS Properties
*/
@@ -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 {
display: flex;
@@ -9,29 +14,9 @@ $content-max-width: 1280px;
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 {
box-sizing: border-box;
// 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;
margin: 1rem;
overflow-y: auto;
flex-grow: 1;
// room for the last section to scroll to the top of the viewport
padding-bottom: 40vh;
padding-bottom: 300px;
}
@@ -12,12 +12,12 @@ interface PanelContentProps {
export default function PanelContent({ onClose, children }: PropsWithChildren<PanelContentProps>) {
return (
<div className={style.contentWrapper}>
<div className={style.content}>{children}</div>
<div className={style.corner}>
<Button size='large' onClick={onClose}>
Close settings <IoClose />
</Button>
</div>
<div className={style.content}>{children}</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,
ul {
list-style: none;
@@ -16,20 +6,22 @@ ul {
}
.tabs {
width: min(30vw, 300px);
flex: 0 0 min(30vw, 300px);
min-width: min(30vw, 300px);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.primary,
.secondary {
padding: 0.25rem 1rem;
margin-right: 1rem;
border-radius: 2px;
&:focus-visible {
&:focus {
background-color: $gray-1000;
outline: 2px solid $blue-500;
outline-offset: -2px;
outline: 0;
}
&:hover {
@@ -40,21 +32,14 @@ ul {
.primary {
font-size: 1rem;
border-radius: 2px;
display: flex;
align-items: center;
gap: 0.5rem;
border-left: 3px solid transparent;
&.groupActive {
color: $blue-400;
font-weight: 600;
}
&.active {
color: $ui-white;
border-left-color: $blue-400;
background-color: $bg-active;
font-weight: 600;
color: $blue-400;
background-color: $gray-1100;
}
&.highlight {
@@ -77,9 +62,6 @@ ul {
font-size: $inner-section-text-size;
&.active {
color: $ui-white;
border-left-color: $blue-400;
background-color: $bg-active;
font-weight: 600;
color: $blue-400;
}
}
@@ -1,18 +1,10 @@
import { Fragment, useState } from 'react';
import { Fragment } from 'react';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { isKeyEnter } from '../../../common/utils/keyEvent';
import { cx } from '../../../common/utils/styleUtils';
import * as Panel from '../panel-utils/PanelUtils';
import {
filterSettingsOptions,
matchesSettingsOptionQuery,
SettingsOption,
SettingsOptionId,
useAppSettingsMenu,
} from '../useAppSettingsMenu';
import { SettingsOption, SettingsOptionId, useAppSettingsMenu } from '../useAppSettingsMenu';
import useAppSettingsNavigation from '../useAppSettingsNavigation';
import SettingsSearch from './SettingsSearch';
import style from './PanelList.module.scss';
@@ -24,57 +16,23 @@ interface PanelListProps extends PanelBaseProps {
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) {
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 (
<div className={style.container}>
<SettingsSearch query={query} onQueryChange={setQuery} onSubmit={handleSearchSubmit} />
{results.length === 0 ? (
<Panel.EmptyState title='No settings match' description={`Nothing found for "${query.trim()}"`} />
) : (
<ul className={style.tabs}>
{results.map((panel) => {
const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
return (
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
</Tooltip>
);
}
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
)}
</div>
<ul className={style.tabs}>
{options.map((panel) => {
const isSelected = selectedPanel === panel.id;
if (panel.highlight) {
return (
<Tooltip key={panel.id} text={panel.highlight} render={<span />}>
<PanelListItem panel={panel} location={location} isSelected={isSelected} />
</Tooltip>
);
}
return <PanelListItem key={panel.id} panel={panel} location={location} isSelected={isSelected} />;
})}
</ul>
);
}
@@ -86,15 +44,7 @@ interface PanelListItemProps {
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
const { setLocation } = useAppSettingsNavigation();
const hasSelectedChild = Boolean(
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,
]);
const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
return (
<Fragment key={panel.id}>
@@ -125,7 +75,6 @@ function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
}
}}
className={secondaryClasses}
tabIndex={0}
role='button'
>
{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;
$card-padding: 2rem;
.header {
font-size: 1.5rem;
font-size: 2rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid $white-10;
font-weight: 600;
}
.subheader {
font-size: 1.125rem;
font-size: 1.5rem;
padding-bottom: 0.5rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.title {
font-size: 1rem;
padding: 0 var(--panel-card-padding, #{$card-padding});
font-size: 1.375rem;
padding: 0 2rem;
font-weight: 600;
display: flex;
align-items: center;
@@ -37,12 +35,10 @@ $card-padding: 2rem;
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 {
position: relative;
margin-top: 2rem;
max-width: 850px;
}
.indent {
@@ -55,10 +51,8 @@ $card-padding: 2rem;
}
.card {
--panel-card-padding: #{$card-padding};
position: relative;
padding: var(--panel-card-padding);
padding: 2rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: 3px;
@@ -82,10 +76,10 @@ $card-padding: 2rem;
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 {
padding: 0 var(--panel-card-padding, #{$card-padding});
padding: 0 2rem;
max-height: 550px;
overflow-y: scroll;
}
.table {
@@ -103,13 +97,10 @@ $card-padding: 2rem;
}
th {
font-weight: 600;
color: $gray-300;
font-size: calc(1rem - 3px);
letter-spacing: 0.02em;
vertical-align: bottom;
font-weight: 400;
color: $gray-400;
white-space: nowrap;
text-transform: uppercase;
text-transform: capitalize;
}
th,
@@ -121,17 +112,6 @@ $card-padding: 2rem;
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
tr[data-warn='true'],
td[data-warn='true'] {
@@ -140,7 +120,7 @@ $card-padding: 2rem;
}
.listGroup {
padding: 0 var(--panel-card-padding, #{$card-padding});
padding: 0 2rem;
> li:not(:last-child) {
border-bottom: 1px solid $white-10;
@@ -153,17 +133,6 @@ $card-padding: 2rem;
align-items: center;
gap: 1rem;
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 {
@@ -194,13 +163,14 @@ $card-padding: 2rem;
.divider {
border: none;
border-top: 1px solid $white-10;
margin: 1rem calc(var(--panel-card-padding, #{$card-padding}) * -1);
margin: 1rem -2rem;
}
.overlay {
position: absolute;
z-index: $zindex-backdrop;
inset: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(2px);
display: grid;
place-content: center;
@@ -218,35 +188,19 @@ $card-padding: 2rem;
animation: animloader 1s ease-in infinite;
}
.emptyState {
padding: 3rem 1.5rem;
.empty {
background-color: $black-10;
text-align: center;
}
color: $muted-gray;
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
td {
padding-block: 5rem;
}
.emptyTitle {
margin-bottom: 0.25rem;
color: rgba($gray-200, 0.72);
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;
button {
margin-top: 1rem;
margin-inline: auto;
}
}
.inlineElements {
@@ -285,3 +239,14 @@ $card-padding: 2rem;
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 { IoAdd } from 'react-icons/io5';
import Button from '../../../common/components/buttons/Button';
import { cx } from '../../../common/utils/styleUtils';
import style from './PanelUtils.module.scss';
@@ -60,29 +62,16 @@ export function Table({ className, children }: { className?: string; children: R
);
}
export interface EmptyStateProps {
title: string;
description?: ReactNode;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
return (
<div className={style.emptyState}>
<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>
<tr className={style.empty}>
<td colSpan={99}>
<EmptyState {...props} />
<div>{label ?? 'No data yet'}</div>
{handleClick && (
<Button onClick={handleClick} disabled={!handleClick} variant='primary'>
New <IoAdd />
</Button>
)}
</td>
</tr>
);
@@ -92,8 +81,8 @@ export function ListGroup({ className, children }: { className?: string; childre
return <ul className={cx([style.listGroup, className])}>{children}</ul>;
}
export function ListItem({ children, interactive = false }: { children: ReactNode; interactive?: boolean }) {
return <li className={cx([style.listItem, interactive && style.interactive])}>{children}</li>;
export function ListItem({ children }: { children: ReactNode }) {
return <li className={style.listItem}>{children}</li>;
}
export function Field({
@@ -1,4 +1,4 @@
import Info from '../../../../common/components/info/Info';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
import {
buyMeACoffeeUrl,
discordUrl,
@@ -11,8 +11,6 @@ import {
} from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion';
import CloudPanel from './CloudPanel';
import ExternalLinkRow from './ExternalLinkRow';
export default function AboutPanel() {
return (
@@ -21,49 +19,28 @@ export default function AboutPanel() {
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Divider />
<Info>Free, open-source software for managing rundowns and event timers.</Info>
<Panel.ListGroup>
<ExternalLinkRow href={websiteUrl} title='www.getontime.no' description='App site and downloads' />
<ExternalLinkRow
href={githubSponsorUrl}
title='GitHub Sponsors'
description='Support development through GitHub'
/>
<ExternalLinkRow
href={buyMeACoffeeUrl}
title='Buy Me a Coffee'
description='Make a one-time contribution'
/>
</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.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
Considering sponsoring our work
<ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
If you are looking for an online version of Ontime, consider trying out our cloud service
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</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>
</>
);
@@ -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 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 style from './AppVersion.module.scss';
export default function AppVersion() {
const { data, isError } = useAppVersion();
if (isError) {
return (
<Panel.ListItem>
<Panel.Field title={`Ontime ${appVersion}`} description='' error='Could not fetch version information' />
</Panel.ListItem>
<Panel.Paragraph>
{`You are currently using Ontime version ${appVersion}`}
<Panel.Error>Could not fetch version information</Panel.Error>
</Panel.Paragraph>
);
}
if (data.hasUpdates) {
return (
<Panel.ListItem>
<Panel.Field
title={
<>
<span className={style.updateIndicator} aria-hidden='true' />
{`Ontime ${appVersion}`}
</>
}
description={
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.Paragraph>
{`You are currently using Ontime version ${appVersion}.`}
<br />
<br />
{`A new version ${data.version} is available.`} <br />
{isOntimeCloud ? (
'You can restart your stage to get the latest available version.'
) : (
<ExternalLink href={data.url}>Please visit the release page to download</ExternalLink>
)}
</Panel.ListItem>
</Panel.Paragraph>
);
}
return (
<Panel.ListItem>
<Panel.Field title={`Ontime ${appVersion}`} description='You are using the latest version.' />
</Panel.ListItem>
);
return <Panel.Paragraph>{`You are currently using the latest version of Ontime: ${appVersion}`}</Panel.Paragraph>;
}
@@ -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,13 +1,6 @@
.outerColumn {
display: flex;
flex-direction: column;
gap: 2rem;
font-size: calc(1rem - 1px);
color: $ui-white;
// the shared modal body owns scrolling for this regular form modal
min-height: 100%;
padding-block: 0.5rem;
margin-bottom: 2rem;
h3 {
font-size: 1rem;
@@ -19,12 +19,12 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
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 Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
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 { isAutomation, makeFieldList } from './automationUtils';
@@ -34,7 +34,6 @@ import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const formId = 'automation-form';
interface AutomationFormProps {
automation: Automation | AutomationDTO;
@@ -186,320 +185,284 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title={isEdit ? 'Edit automation' : 'Create automation'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
<div className={style.innerColumn}>
<h3>Automation options</h3>
<div className={style.titleSection}>
<label>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
placeholder='Load preset'
/>
</label>
<Panel.Error>{errors.title?.message}</Panel.Error>
</div>
</div>
<Panel.Indent
as='form'
name='automation-form'
onSubmit={handleSubmit(onSubmit)}
className={style.outerColumn}
onKeyDown={(event) => preventEscape(event, onClose)}
>
<Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader>
<div className={style.innerSection}>
<h3>Automation options</h3>
<div className={style.titleSection}>
<label>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
placeholder='Load preset'
/>
</label>
<Panel.Error>{errors.title?.message}</Panel.Error>
</div>
</div>
<div className={style.innerColumn}>
<h3>Filters (optional)</h3>
<div className={style.ruleSection}>
<label>
Trigger outputs if
<RadioGroup
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>
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
| 'equals'
| 'not_equals'
| 'greater_than'
| 'less_than'
| 'contains'
| 'not_contains',
{ shouldDirty: true },
);
}}
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 className={style.innerSection}>
<h3>Filters (optional)</h3>
<div className={style.ruleSection}>
<label>
Trigger outputs if
<RadioGroup
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>
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 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains',
{ shouldDirty: true },
);
}}
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>
<Button onClick={handleAddNewFilter}>
Add filter <IoAdd />
</Button>
</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>
<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.
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</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;
{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 />
</IconButton>
</Panel.InlineElements>
</div>
</div>
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
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} 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 />
</IconButton>
</Panel.InlineElements>
</div>
);
}
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>HTTP</Tag>
<div className={style.httpSection}>
<label>
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>
<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>
</div>
);
}
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>HTTP</Tag>
<div className={style.httpSection}>
<label>
Target URL
<Input
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should target http:// or https://',
},
})}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<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>
);
}
if (isOntimeAction(output)) {
const rowErrors = errors.outputs?.[index] as
| {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag>
<OntimeActionForm
value={output.action}
index={index}
register={register}
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</OntimeActionForm>
</div>
);
}
if (isOntimeAction(output)) {
const rowErrors = errors.outputs?.[index] as
| {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>Ontime action</Tag>
<OntimeActionForm
value={output.action}
index={index}
register={register}
rowErrors={rowErrors}
setValue={setValue}
watch={watch}
>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton aria-label='Delete' variant='ghosted-destructive' onClick={() => removeOutput(index)}>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</OntimeActionForm>
</div>
);
}
return null;
})}
<Panel.InlineElements relation='inner'>
<Button onClick={handleAddNewOSCOutput}>
OSC <IoAdd />
</Button>
<Button onClick={handleAddNewHTTPOutput}>
HTTP <IoAdd />
</Button>
<Button onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd />
</Button>
</Panel.InlineElements>
</div>
</form>
}
footerElements={
<>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}>
Save
// there should be no other output types
return null;
})}
<Panel.InlineElements relation='inner'>
<Button onClick={handleAddNewOSCOutput}>
OSC <IoAdd />
</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 (
<>
<Panel.Header>Automation</Panel.Header>
<div ref={settingsRef}>
<AutomationSettingsForm
enabledAutomations={data.enabledAutomations}
enabledOscIn={data.enabledOscIn}
oscPortIn={data.oscPortIn}
automationState={automationState}
oscInputState={oscInputState}
isLoading={isLoading}
/>
</div>
<div ref={automationsRef}>
<AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} />
</div>
<div ref={triggersRef}>
<TriggersList
triggers={data.triggers}
automations={data.automations}
enabledAutomations={automationState}
isLoading={isLoading}
/>
</div>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<div ref={settingsRef}>
<AutomationSettingsForm
enabledAutomations={data.enabledAutomations}
enabledOscIn={data.enabledOscIn}
oscPortIn={data.oscPortIn}
automationState={automationState}
oscInputState={oscInputState}
/>
</div>
<div ref={automationsRef}>
<AutomationsList automations={data.automations} enabledAutomations={automationState} />
</div>
<div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} enabledAutomations={automationState} />
</div>
</Panel.Section>
</>
);
}
@@ -21,7 +21,6 @@ interface AutomationSettingsProps {
oscPortIn: number;
automationState?: boolean;
oscInputState?: boolean;
isLoading?: boolean;
}
export default function AutomationSettingsForm({
@@ -30,7 +29,6 @@ export default function AutomationSettingsForm({
oscPortIn,
automationState,
oscInputState,
isLoading,
}: AutomationSettingsProps) {
const {
handleSubmit,
@@ -67,129 +65,126 @@ export default function AutomationSettingsForm({
const oscInputEnabled = watch('enabledOscIn');
return (
<Panel.Section>
<Panel.Card>
<Panel.Loader isLoading={Boolean(isLoading)} />
<Panel.SubHeader>
Automation settings
<Panel.InlineElements>
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='primary'
type='submit'
form='automation-settings-form'
disabled={!canSubmit}
loading={isSubmitting}
>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Card>
<Panel.SubHeader>
Automation settings
<Panel.InlineElements>
<Button variant='ghosted' onClick={onReset} disabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='primary'
type='submit'
form='automation-settings-form'
disabled={!canSubmit}
loading={isSubmitting}
>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Divider />
<Panel.Divider />
<Panel.Section>
<Info>
<span>Control Ontime and share its data with external systems in your workflow.</span>
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info>
</Panel.Section>
<Panel.Section>
<Info>
<span>Control Ontime and share its data with external systems in your workflow.</span>
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info>
</Panel.Section>
<Panel.Section
as='form'
id='automation-settings-form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
>
<Panel.Title>Automation</Panel.Title>
<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.Section
as='form'
id='automation-settings-form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
>
<Panel.Loader isLoading={false} />
<Panel.ListGroup>
{isOntimeCloud && (
<Info>For security reasons OSC integrations are not available in the cloud service.</Info>
)}
<Panel.ListItem>
<Panel.Field
title={
<>
<span>OSC input</span>
{oscInputState === false && <Tag variant='warning'>OFF</Tag>}
</>
}
description={
oscInputState === false
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
: 'Allow control of Ontime through OSC'
}
descriptionTone={oscInputState === false ? 'warning' : 'default'}
error={errors.enabledOscIn?.message}
/>
<Switch
size='large'
checked={oscInputEnabled}
onCheckedChange={(value: boolean) =>
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
}
/>
</Panel.ListItem>
<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>
</Panel.Section>
<Panel.Title>Automation</Panel.Title>
<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>
{isOntimeCloud && <Info>For security reasons OSC integrations are not available in the cloud service.</Info>}
<Panel.ListItem>
<Panel.Field
title={
<>
<span>OSC input</span>
{oscInputState === false && <Tag variant='warning'>OFF</Tag>}
</>
}
description={
oscInputState === false
? 'OSC input is OFF. Ontime will not listen for incoming OSC control messages.'
: 'Allow control of Ontime through OSC'
}
descriptionTone={oscInputState === false ? 'warning' : 'default'}
error={errors.enabledOscIn?.message}
/>
<Switch
size='large'
checked={oscInputEnabled}
onCheckedChange={(value: boolean) =>
setValue('enabledOscIn', value, { shouldDirty: true, shouldValidate: true })
}
/>
</Panel.ListItem>
<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>
);
}
@@ -22,10 +22,10 @@ const automationPlaceholder: AutomationDTO = {
interface AutomationsListProps {
automations: NormalisedAutomation;
enabledAutomations?: boolean;
isLoading: boolean;
}
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
export default function AutomationsList(props: AutomationsListProps) {
const { automations, enabledAutomations } = props;
const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -44,94 +44,89 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
const arrayAutomations = Object.keys(automations);
return (
<Panel.Section>
<Panel.Card>
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.SubHeader>
Manage automations
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
New <IoAdd />
</Button>
</Panel.SubHeader>
<Panel.Card>
<Panel.SubHeader>
Manage automations
<Button
type='submit'
disabled={Boolean(automationFormData)}
onClick={() => setAutomationFormData(automationPlaceholder)}
>
New <IoAdd />
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Divider />
<Panel.Section>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage automation definitions here, but they will not run until
enabled.
</Info>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage automation definitions here, but they will not run until
enabled.
</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}
/>
)}
<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>
{!isLoading && arrayAutomations.length === 0 && (
<Panel.TableEmpty
title='No automations yet'
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
action={
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
Create automation <IoAdd />
</Button>
}
/>
)}
{arrayAutomations.map((automationId) => {
if (!Object.hasOwn(automations, automationId)) {
return null;
}
return (
<Fragment key={automationId}>
<tr>
<td>{automations[automationId].title}</td>
<td>
<Tag>{automations[automationId].filterRule}</Tag>
</td>
<td>{automations[automationId].filters.length}</td>
<td>{automations[automationId].outputs.length}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])}
>
<IoPencil />
</IconButton>
<IconButton
variant='ghosted-destructive'
aria-label='Delete entry'
onClick={() => handleDelete(automationId)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</tr>
</Fragment>
);
})}
{deleteError && (
{arrayAutomations.map((automationId) => {
if (!Object.hasOwn(automations, automationId)) {
return null;
}
return (
<Fragment key={automationId}>
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
<td>{automations[automationId].title}</td>
<td>
<Tag>{automations[automationId].filterRule}</Tag>
</td>
<td>{automations[automationId].filters.length}</td>
<td>{automations[automationId].outputs.length}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])}
>
<IoPencil />
</IconButton>
<IconButton
variant='ghosted-destructive'
aria-label='Delete entry'
onClick={() => handleDelete(automationId)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</tr>
)}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
</Panel.Section>
</Fragment>
);
})}
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody>
</Panel.Table>
</Panel.Card>
);
}
@@ -5,7 +5,6 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
@@ -72,8 +71,8 @@ export default function OntimeActionForm({
{ value: 'playback-pause', label: 'Playback: pause' },
{ value: 'playback-roll', label: 'Playback: roll' },
{ value: 'message-set', label: 'Primary Message' },
{ value: 'message-secondary', label: 'Secondary Message' },
{ value: 'message-set', label: 'Primary Message: set' },
{ value: 'message-secondary', label: 'Secondary Message: source' },
]}
/>
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
@@ -97,12 +96,7 @@ export default function OntimeActionForm({
<>
<label>
Text (leave empty for no change)
<TemplateInput
{...register(`outputs.${index}.text`)}
value={watch(`outputs.${index}.text`) ?? ''}
fluid
placeholder='eg: Timer is finished'
/>
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label>
@@ -126,48 +120,31 @@ export default function OntimeActionForm({
)}
{selectedAction === 'message-secondary' && (
<>
<label>
Text (leave empty for no change)
<TemplateInput
{...register(`outputs.${index}.text`)}
value={watch(`outputs.${index}.text`) ?? ''}
fluid
placeholder='eg: Next up: keynote'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label>
Timer secondary source
<Select<SecondarySource | 'no-change' | 'null' | null>
onValueChange={(value) => {
// null -> no selection
if (value === null) return;
// no-change -> leave the current secondary source untouched
if (value === 'no-change') {
setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true });
return;
}
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
options={[
{ value: 'no-change', label: 'No change' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
</>
<label>
Timer secondary source
<Select<SecondarySource | 'null' | null>
onValueChange={(value) => {
// null -> no selection
if (value === null) return;
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`)}
options={[
{ value: null, label: 'Select secondary source' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
)}
<div className={style.test}>{children}</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 { useForm } from 'react-hook-form';
@@ -6,21 +6,30 @@ import { addTrigger, editTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import Select from '../../../../common/components/select/Select';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils';
const formId = 'trigger-form';
interface TriggerFormProps {
automations: NormalisedAutomation;
trigger: Trigger | null;
initialId?: string;
initialTitle?: string;
initialAutomationId?: string;
initialTrigger?: TimerLifeCycle;
onCancel: () => void;
postSubmit: () => void;
}
export default function TriggerForm({ automations, trigger, onCancel, postSubmit }: TriggerFormProps) {
export default function TriggerForm({
automations,
initialId,
initialTitle,
initialAutomationId,
initialTrigger,
onCancel,
postSubmit,
}: TriggerFormProps) {
const {
handleSubmit,
register,
@@ -31,9 +40,9 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<TriggerDTO>({
defaultValues: {
title: trigger?.title,
trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id,
title: initialTitle,
trigger: initialTrigger ?? (cycles[0].value as TimerLifeCycle | undefined),
automationId: initialAutomationId ?? automations?.[Object.keys(automations)[0]]?.id,
},
resetOptions: {
keepDirtyValues: true,
@@ -46,9 +55,10 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
}, [setFocus]);
const onSubmit = async (values: TriggerDTO) => {
if (trigger) {
// if we were passed an ID we are editing a Trigger
if (initialId) {
try {
await editTrigger(trigger.id, { id: trigger.id, ...values });
await editTrigger(initialId, { id: initialId, ...values });
postSubmit();
} catch (error) {
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
@@ -75,63 +85,56 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
const canSubmit = isDirty && isValid;
return (
<Modal
isOpen
onClose={onCancel}
showBackdrop
showCloseButton
size='compact'
title={trigger ? 'Edit trigger' : 'Create trigger'}
bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
<label>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
defaultValue={trigger?.title}
/>
<Panel.Error>{errors.title?.message}</Panel.Error>
</label>
<label>
Lifecycle trigger
<Select
value={watch('trigger')}
onValueChange={(value) => {
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'
/>
<Panel.Error>{errors.trigger?.message}</Panel.Error>
</label>
<label>
Automation title
<Select
value={watch('automationId')}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue('automationId', value, { shouldDirty: true });
}}
options={automationSelect}
aria-label='Automation title'
/>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
</form>
}
footerElements={
<>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button disabled={isSubmitting} onClick={onCancel}>
Cancel
</Button>
<Button type='submit' form={formId} variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</>
}
/>
<Panel.Indent
as='form'
name='trigger-form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onCancel)}
>
<Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader>
<label>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
fluid
defaultValue={initialTitle}
/>
<Panel.Error>{errors.title?.message}</Panel.Error>
</label>
<label>
Lifecycle trigger
<Select
value={watch('trigger')}
onValueChange={(value) => {
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'
/>
<Panel.Error>{errors.trigger?.message}</Panel.Error>
</label>
<label>
Automation title
<Select
value={watch('automationId')}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue('automationId', value, { shouldDirty: true });
}}
options={automationSelect}
aria-label='Automation title'
/>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
<Panel.InlineElements align='end'>
<Button disabled={isSubmitting} onClick={onCancel}>
Cancel
</Button>
<Button type='submit' variant='primary' disabled={!canSubmit} loading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -9,33 +9,23 @@ import Info from '../../../../common/components/info/Info';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
import { checkDuplicates } from './automationUtils';
import TriggerForm from './TriggerForm';
import AutomationForm from './TriggerForm';
import TriggersListItem from './TriggersListItem';
type FormState = {
isOpen: boolean;
trigger?: Trigger;
};
interface TriggersListProps {
triggers: Trigger[];
automations: NormalisedAutomation;
enabledAutomations?: boolean;
isLoading: boolean;
}
export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) {
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
export default function TriggersList(props: TriggersListProps) {
const { triggers, automations, enabledAutomations } = props;
const [showForm, setShowForm] = useState(false);
const { refetch } = useAutomationSettings();
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) => {
try {
setDeleteError(null);
await deleteTrigger(id);
} catch (error) {
setDeleteError(maybeAxiosError(error));
@@ -45,7 +35,7 @@ export default function TriggersList({ triggers, automations, enabledAutomations
};
const postSubmit = () => {
closeForm();
setShowForm(false);
refetch();
};
@@ -55,86 +45,70 @@ export default function TriggersList({ triggers, automations, enabledAutomations
const canAdd = Object.keys(automations).length > 0;
return (
<Panel.Section>
<Panel.Card>
{formState.isOpen && (
<TriggerForm
automations={automations}
trigger={formState.trigger ?? null}
onCancel={closeForm}
postSubmit={postSubmit}
/>
<Panel.Card>
<Panel.SubHeader>
Manage triggers
<Button type='submit' form='trigger-form' disabled={!canAdd} loading={false} onClick={() => setShowForm(true)}>
New <IoAdd />
</Button>
</Panel.SubHeader>
<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>
Manage triggers
<Button disabled={!canAdd} onClick={openNewForm}>
New <IoAdd />
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
{enabledAutomations === false && (
<Info>
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
</Info>
)}
{duplicates && (
<Panel.Error>
You have created multiple links between the same trigger and automation which can cause performance
issues.
</Panel.Error>
)}
<Panel.Table>
<thead>
<tr>
<th style={{ width: '35%' }}>Title</th>
<th style={{ width: '25%' }}>Lifecycle trigger</th>
<th style={{ width: '25%' }}>Automation</th>
<th />
</tr>
</thead>
<tbody>
{!isLoading && triggers.length === 0 && (
<Panel.TableEmpty
title='No triggers yet'
description={
canAdd
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
: 'Create an automation first, then add a trigger to decide when it should run.'
}
action={
canAdd && (
<Button variant='primary' onClick={openNewForm}>
Create trigger <IoAdd />
</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>
{duplicates && (
<Panel.Error>
You have created multiple links between the same trigger and automation which can performance issues.
</Panel.Error>
)}
{showForm && (
<AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
)}
<Panel.Table>
<thead>
<tr>
<th style={{ width: '35%' }}>Title</th>
<th style={{ width: '25%' }}>Lifecycle trigger</th>
<th style={{ width: '25%' }}>Automation</th>
<th />
</tr>
</thead>
<tbody>
{!showForm && triggers.length === 0 && (
<Panel.TableEmpty
label='Create an automation to attach triggers to'
handleClick={canAdd ? () => setShowForm(true) : undefined}
/>
)}
{triggers.map((trigger, index) => {
return (
<Fragment key={trigger.id}>
<TriggersListItem
automations={automations}
id={trigger.id}
title={trigger.title}
trigger={trigger.trigger}
automationId={trigger.automationId}
duplicate={duplicates?.includes(index)}
handleDelete={() => handleDelete(trigger.id)}
postSubmit={postSubmit}
/>
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
);
}
@@ -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 IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils';
import AutomationForm from './TriggerForm';
interface TriggersListItemProps {
automations: NormalisedAutomation;
trigger: Trigger;
id: string;
title: string;
trigger: TimerLifeCycle;
automationId: string;
duplicate?: boolean;
handleEdit: () => void;
handleDelete: () => void;
postSubmit: () => void;
}
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 (
<tr data-warn={duplicate}>
@@ -25,16 +52,16 @@ export default function TriggersListItem(props: TriggersListItemProps) {
color='#FFBC56' // $orange-500
/>
)}
{trigger.title}
{title}
</Panel.InlineElements>
<td>
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
</td>
<td>
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
<Tag>{automations?.[automationId]?.title}</Tag>
</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 />
</IconButton>
<IconButton variant='ghosted-destructive' aria-label='Delete entry' onClick={handleDelete}>
@@ -1,67 +1,29 @@
.inputShell {
.wrapper {
position: relative;
}
.fluid {
width: 100%;
}
.input {
padding-right: 2rem;
}
.expandButton {
position: absolute;
top: 50%;
right: 0.25rem;
transform: translateY(-50%);
}
.positioner {
outline: 0;
}
.popup {
box-sizing: border-box;
min-width: var(--anchor-width);
max-width: var(--available-width);
border: 1px solid $gray-1000;
border-radius: $component-border-radius-md;
.suggestions {
background: $gray-1250;
color: $label-gray;
}
color: $ui-white;
.list {
box-sizing: border-box;
max-height: min(20rem, var(--available-height));
overflow-y: auto;
overscroll-behavior: contain;
position: absolute;
top: 100%;
left: 0;
width: 100%;
margin: 0;
z-index: $zindex-floating;
padding-block: 0.25rem;
outline: 0;
}
.item {
box-sizing: border-box;
padding: 0.25rem 0.5rem;
outline: 0;
cursor: default;
user-select: none;
overflow-wrap: anywhere;
max-height: 200px;
overflow-y: auto;
color: $label-gray;
&[data-highlighted] {
li {
padding: 0.25rem;
}
li:hover {
color: $ui-white;
background: $blue-700;
}
}
.expandedEditor {
min-height: min(18rem, 45vh);
font-family: monospace;
line-height: 1.4;
}
.footerHint {
margin-right: auto;
color: $label-gray;
font-size: calc(1rem - 2px);
}
@@ -1,258 +1,69 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import type { ChangeEvent, ReactNode, Ref } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoExpandOutline } from 'react-icons/io5';
import { mergeRefs, useClickOutside } from '@mantine/hooks';
import { forwardRef, useMemo, useState } from 'react';
import Button from '../../../../../common/components/buttons/Button';
import IconButton from '../../../../../common/components/buttons/IconButton';
import { type InputProps } from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { cx } from '../../../../../common/utils/styleUtils';
import { makeAutoCompleteList } from './templateInput.utils';
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
import style from './TemplateInput.module.scss';
interface TemplateInputProps extends Omit<InputProps, 'value'> {
ref?: Ref<HTMLInputElement>;
value?: string;
}
interface TemplateInputProps extends InputProps {}
interface TemplateEditorModalProps {
autocompleteList: string[];
isOpen: boolean;
onClose: () => void;
onSave: (value: string) => void;
value: string;
}
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
interface TemplateAutocompleteRootProps {
autocomplete: TemplateAutocompleteState;
children: ReactNode;
value: string;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
ref.current = value;
}
}
function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
onChange?.({
target: { name, value },
currentTarget: { name, value },
} as ChangeEvent<HTMLInputElement>);
}
export default function TemplateInput({
className,
disabled,
fluid,
height = 'medium',
onChange,
readOnly,
ref,
value,
variant = 'subtle',
...rest
}: TemplateInputProps) {
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
const { value, onChange, ...rest } = props;
const { data } = useCustomFields();
const inputRef = useRef<HTMLInputElement | null>(null);
const [inputValue, setInputValue] = useState(value || '');
const [isExpanded, setIsExpanded] = useState(false);
const localRef = useClickOutside(() => setShowSuggestions(false));
const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data);
}, [data]);
const updateInputValue = useCallback(
(nextValue: string) => {
setInputValue(nextValue);
emitInputChange(rest.name, nextValue, onChange);
},
[onChange, rest.name],
);
const [inputValue, setInputValue] = useState(value || '');
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue);
const { setCursorForValue } = autocomplete;
// Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
useEffect(() => {
const nextValue = value || '';
setInputValue(nextValue);
setCursorForValue(nextValue, nextValue.length);
}, [setCursorForValue, value]);
const setInputRef = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
assignRef(ref, element);
},
[ref],
);
const openExpandedEditor = () => {
autocomplete.setShowSuggestions(false);
setIsExpanded(true);
const updateSuggestions = (value: string) => {
const template = selectFromLastTemplate(value);
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
};
const closeExpandedEditor = () => {
setIsExpanded(false);
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
const saveExpandedEditor = (nextValue: string) => {
updateInputValue(nextValue);
autocomplete.setCursorForValue(nextValue, nextValue.length);
setIsExpanded(false);
};
return (
<>
<TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
<div className={cx([style.inputShell, fluid && style.fluid])}>
<BaseAutocomplete.Input
ref={setInputRef}
className={cx([
inputStyle.input,
inputStyle[variant],
inputStyle[height],
fluid && inputStyle.fluid,
style.input,
className,
])}
{...rest}
disabled={disabled}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
readOnly={readOnly}
/>
<IconButton
aria-label='Expand template editor'
className={style.expandButton}
disabled={disabled || readOnly}
onClick={openExpandedEditor}
size='small'
title='Expand template editor'
variant='ghosted-white'
>
<IoExpandOutline />
</IconButton>
</div>
</TemplateAutocompleteRoot>
<TemplateEditorModal
autocompleteList={autocompleteList}
isOpen={isExpanded}
onClose={closeExpandedEditor}
onSave={saveExpandedEditor}
value={inputValue}
/>
</>
);
}
function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
return (
<BaseAutocomplete.Root
items={autocomplete.suggestions}
autoHighlight
highlightItemOnHover
mode='none'
open={autocomplete.open}
value={value}
onOpenChange={autocomplete.setShowSuggestions}
onValueChange={autocomplete.handleValueChange}
>
{children}
<TemplateSuggestionPopup />
</BaseAutocomplete.Root>
);
}
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
const expandedInputRef = useRef<HTMLTextAreaElement | null>(null);
const [draftValue, setDraftValue] = useState(value);
const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
const { setShowSuggestions } = autocomplete;
// Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
useEffect(() => {
if (!isOpen) {
return;
if (event.target.value.endsWith('{')) {
setShowSuggestions(true);
setSuggestions(updateSuggestions(event.target.value));
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
setShowSuggestions(false);
} else if (showSuggestions) {
setSuggestions(updateSuggestions(event.target.value));
}
setDraftValue(value);
setShowSuggestions(false);
}, [isOpen, setShowSuggestions, value]);
const handleClose = () => {
setShowSuggestions(false);
onClose();
onChange?.(event);
};
const handleSave = () => {
const handleSuggestion = (value: string) => {
setInputValue((prev) => {
const remaining = matchRemaining(prev as string, value);
return prev + remaining;
});
setShowSuggestions(false);
onSave(draftValue);
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
showBackdrop
showCloseButton
title='Edit template'
bodyElements={
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}>
<BaseAutocomplete.Input
autoFocus
className={style.expandedEditor}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
/>
</TemplateAutocompleteRoot>
}
footerElements={
<>
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSave} variant='primary'>
Save
</Button>
</>
}
/>
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
<Input value={inputValue} {...rest} onChange={handleInputChange} fluid />
{showSuggestions && suggestions.length > 0 && (
<ul className={style.suggestions}>
{suggestions.map((suggestion) => (
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
{suggestion}
</li>
))}
</ul>
)}
</div>
);
}
});
function TemplateSuggestionPopup() {
return (
<BaseAutocomplete.Portal>
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
<BaseAutocomplete.Popup className={style.popup}>
<BaseAutocomplete.List className={style.list}>
<BaseAutocomplete.Collection>
{(suggestion: string) => (
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
{suggestion}
</BaseAutocomplete.Item>
)}
</BaseAutocomplete.Collection>
</BaseAutocomplete.List>
</BaseAutocomplete.Popup>
</BaseAutocomplete.Positioner>
</BaseAutocomplete.Portal>
);
}
export default TemplateInput;
@@ -1,4 +1,4 @@
import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils';
import { matchRemaining } from '../templateInput.utils';
describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => {
@@ -15,54 +15,3 @@ describe('matchRemaining()', () => {
expect(matchRemaining('test', 'test')).toBe('');
});
});
describe('selectActiveTemplate()', () => {
it('returns the last unclosed template fragment', () => {
expect(selectActiveTemplate('send {{event')).toBe('{{event');
expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
});
it('ignores single braces and closed templates', () => {
expect(selectActiveTemplate('send {')).toBe('');
expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
});
it('only considers templates before the cursor', () => {
expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
});
it('selects a partial template when the cursor is inside a completed template', () => {
expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
});
});
describe('completeTemplateAtCursor()', () => {
it('completes the active template before the cursor', () => {
expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
value: 'send {{timer.current}} after',
cursorIndex: 22,
});
});
it('preserves text before and after the cursor', () => {
expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
value: 'before {{eventNow.title}} after',
cursorIndex: 25,
});
});
it('preserves a following template when completing between templates', () => {
expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
cursorIndex: 31,
});
});
it('replaces the whole template when the cursor is inside a completed template', () => {
expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
value: 'before {{timer.duration}} after',
cursorIndex: 25,
});
});
});
@@ -54,16 +54,6 @@ const eventStaticPropertiesNext = [
'{{eventNext.delay}}',
];
const groupStaticPropertiesNow = [
'{{groupNow.id}}',
'{{groupNow.title}}',
'{{groupNow.note}}',
'{{groupNow.colour}}',
'{{groupNow.timeStart}}',
'{{groupNow.timeEnd}}',
'{{groupNow.duration}}',
];
const staticAuxProperties = (index: 1 | 2 | 3) => [
`{{auxtimer${index}.current}}`,
`{{auxtimer${index}.duration}}`,
@@ -85,19 +75,12 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
...groupStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
...staticAuxProperties(1),
...staticAuxProperties(2),
...staticAuxProperties(3),
];
}
interface TemplateCompletion {
cursorIndex: number;
value: string;
}
/**
* Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st'
@@ -128,54 +111,13 @@ export function matchRemaining(a: string, b: string) {
return '';
}
function getActiveTemplateRange(text: string, cursorIndex = text.length) {
const textBeforeCursor = text.slice(0, cursorIndex);
const start = textBeforeCursor.lastIndexOf('{{');
if (start === -1) {
return null;
}
const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
if (closeBeforeCursor > start) {
return null;
}
const closeAfterStart = text.indexOf('}}', start);
const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
return {
end,
start,
template: text.slice(start, cursorIndex),
};
}
/**
* Selects the last unclosed starting template before the cursor.
* Selects the last starting template in a string
*/
export function selectActiveTemplate(text: string, cursorIndex = text.length) {
return getActiveTemplateRange(text, cursorIndex)?.template ?? '';
}
/**
* Replaces the active template fragment before the cursor with the selected suggestion.
*/
export function completeTemplateAtCursor(
text: string,
suggestion: string,
cursorIndex = text.length,
): TemplateCompletion {
const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
if (!activeTemplateRange) {
const value = text + matchRemaining(text, suggestion);
return { value, cursorIndex: value.length };
export function selectFromLastTemplate(text: string) {
const lastBraceIndex = text.lastIndexOf('{{');
if (lastBraceIndex !== -1) {
return text.slice(lastBraceIndex);
}
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
return {
value,
cursorIndex: activeTemplateRange.start + suggestion.length,
};
return '';
}
@@ -1,72 +0,0 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import { useCallback, useMemo, useState, type RefObject } from 'react';
import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
function getCursorIndex(element: TemplateElement | null, fallback: string) {
return element?.selectionStart ?? fallback.length;
}
function focusCursor(element: TemplateElement | null, cursorIndex: number) {
requestAnimationFrame(() => {
element?.focus();
element?.setSelectionRange(cursorIndex, cursorIndex);
});
}
export function useTemplateAutocomplete<T extends TemplateElement>(
value: string,
autocompleteList: string[],
elementRef: RefObject<T | null>,
onValueChange: (value: string) => void,
) {
const [showSuggestions, setShowSuggestions] = useState(false);
const [cursor, setCursor] = useState(value.length);
const activeTemplate = selectActiveTemplate(value, cursor);
const suggestions = useMemo(() => {
if (!activeTemplate) {
return [];
}
return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
}, [activeTemplate, autocompleteList]);
const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
setCursor(cursorIndex);
setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
}, []);
const updateCursor = useCallback(() => {
const cursorIndex = getCursorIndex(elementRef.current, value);
setCursorForValue(value, cursorIndex);
}, [elementRef, setCursorForValue, value]);
const handleValueChange = useCallback(
(nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
if (eventDetails.reason === 'item-press') {
eventDetails.cancel();
const completed = completeTemplateAtCursor(value, nextValue, cursor);
setCursorForValue(completed.value, completed.cursorIndex);
onValueChange(completed.value);
focusCursor(elementRef.current, completed.cursorIndex);
return;
}
const cursorIndex = getCursorIndex(elementRef.current, nextValue);
setCursorForValue(nextValue, cursorIndex);
onValueChange(nextValue);
},
[cursor, elementRef, onValueChange, setCursorForValue, value],
);
return {
handleValueChange,
open: showSuggestions && suggestions.length > 0,
setCursorForValue,
setShowSuggestions,
suggestions,
updateCursor,
};
}

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