Compare commits

..

127 Commits

Author SHA1 Message Date
Carlos Valente dd781a32f6 bump version to 3.13.1 2025-02-27 08:28:21 +01:00
Carlos Valente c2a6de6195 fix: memoisation of row should allow changes 2025-02-27 08:28:21 +01:00
Carlos Valente 0ac2ce8e84 bump version to 3.13.0 2025-02-26 21:43:24 +01:00
Carlos Valente 3bdd8424eb chore: remove console statement 2025-02-26 21:43:24 +01:00
Carlos Valente b3a099255f fix: missing backstage prop 2025-02-26 21:43:24 +01:00
Carlos Valente 570ff59cfc feat: add custom field type of image 2025-02-26 21:42:24 +01:00
Alex Christoffer Rasmussen d0b4c6a279 import id's from sheet (#1516)
* import id's from sheet

* fix test

* fix test in utils

* remove comment

* ensure uri safe ids
2025-02-26 21:12:36 +01:00
Carlos Valente e90161aa33 refactor: consistent logo sizes 2025-02-25 21:25:28 +01:00
Carlos Valente e37254107e style: reword prompt 2025-02-25 21:25:28 +01:00
Carlos Valente 9d6bc0a137 style: clarify resource 2025-02-25 21:25:28 +01:00
Carlos Valente 4cd2dcbab0 refactor: move logic to service 2025-02-25 21:25:28 +01:00
Carlos Valente 8c593b3487 refactor: use memoised selector 2025-02-23 21:50:01 +01:00
Carlos Valente ba74622569 refactor: use native button 2025-02-23 21:50:01 +01:00
Carlos Valente f76c0d1a6d refactor: improve composition performance 2025-02-23 21:50:01 +01:00
Carlos Valente 8e8d3a7824 refactor: send client list on patch 2025-02-23 21:49:53 +01:00
Carlos Valente 6c3ae1f914 refactor: show schedule in smaller screens 2025-02-23 21:49:53 +01:00
Alex Christoffer Rasmussen b6507c27a6 Report (#1469)
* create report service

* write report from runtimeState

* use in UI

* clear report

* update types

* clear all from settings menu

* rearence rightclik menu

* refactor styling

* also report roll events

* ontime/under time is same colour

* refactor reporter

* add target to ontime-refetch

* remove menu

* fectch only on message from server

* memo useGetEventReport

* refactor

* use staleTime

* dont add to menu yet

* implement review

* clear all from menu

* fix merge

* start end show

* combine test for the go button text and action

* add report to menu

* extract csv utility

* report management

* unneeded async

* small refacort of triggerReportEntry

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-02-23 17:02:09 +01:00
Carlos Valente 21877e4bff refactor: allow redirecting to view 2025-02-22 16:15:24 +01:00
Carlos Valente b8d7324be0 feat: allow redirecting to preset 2025-02-22 16:15:24 +01:00
Carlos Valente 88b8e83494 Time until (#1497)
* Timeuntil in UI (#1461)

* cherry pick EventBlockChip

* pull data down throug components

* add comment

* cleanup css

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* fix: account for running day

* refactor: improve composition

* add partial test

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2025-02-19 13:17:53 +01:00
Carlos Valente abce0c498c bump version to 3.12.0 2025-02-18 21:43:16 +01:00
Carlos Valente 6b2e45e7de refactor: improve template input 2025-02-18 21:43:03 +01:00
Carlos Valente 5fb30c75c4 feat: ontime actions in automation 2025-02-18 21:43:03 +01:00
Carlos Valente 636f78e21e fix: unique reference for filter items 2025-02-18 21:43:03 +01:00
Carlos Valente 1636745fed refactor: improve automation flow logging 2025-02-18 21:43:03 +01:00
Carlos Valente e01ab0c88b fix: update navigation references 2025-02-18 21:43:03 +01:00
Carlos Valente 0ede753652 fix: missing reference on template input 2025-02-18 21:43:03 +01:00
Alex Christoffer Rasmussen 1f6f3df0f2 ensure extension during rename and delete (#1508) 2025-02-18 21:25:36 +01:00
Carlos Valente a5afb32b8a refactor: show projected time 2025-02-18 14:55:24 +01:00
Carlos Valente 9c41f8ff6b refactor: public design review 2025-02-18 14:55:24 +01:00
Carlos Valente 1edde03bb4 chore: directory restructure 2025-02-18 14:55:24 +01:00
Carlos Valente 946b6717d2 refactor: backstage design review
refactor: improve empty state

refactor: review schedule design

- fit as many elements as possible
- expose cycle interval as an option
- stabilise rundown reference
- style tweaks
2025-02-18 14:55:24 +01:00
Carlos Valente f99ad83049 chore: folder structure 2025-02-18 14:55:24 +01:00
Carlos Valente 665496907e refactor: improve overflow style 2025-02-18 14:14:14 +01:00
Carlos Valente 2867c8301d refactor: stabilise table options 2025-02-18 14:14:14 +01:00
Carlos Valente 84a5b4fe5b refactor: use a single menu for table 2025-02-18 14:14:14 +01:00
Carlos Valente 86bb739d90 chore: upgrade zustand 2025-02-18 14:14:14 +01:00
Carlos Valente 2c8b6180d7 refactor: show menu only if enabled 2025-02-18 14:14:14 +01:00
Carlos Valente 4cc138e3c0 chore: directory structure 2025-02-18 14:14:14 +01:00
Carlos Valente d21e1f996f refactor: simplify typing 2025-02-17 21:36:18 +01:00
Carlos Valente 11c1b472a2 refactor: do not test automations without outputs 2025-02-17 21:36:18 +01:00
Carlos Valente 891bda6661 refactor: improve async handling 2025-02-17 21:36:18 +01:00
Carlos Valente c65761448d refactor: simplify import paths 2025-02-17 21:36:18 +01:00
Carlos Valente 70558c5104 refactor: remove unused template literals 2025-02-17 21:36:18 +01:00
Carlos Valente c8bf74a719 refactor: migrate chakra alert to custom component 2025-02-17 14:45:23 +01:00
Carlos Valente 10bf4b93ee doc: add osc disabled warning for cloud users 2025-02-17 14:45:23 +01:00
Carlos Valente f6449a4326 refactor: tweak reusable info component 2025-02-17 14:45:23 +01:00
Carlos Valente 8d38f52b69 bump version to 3.11.1 2025-02-17 12:59:10 +01:00
Carlos Valente 3d40e31730 fix: error on reading malformed package 2025-02-17 12:59:10 +01:00
Carlos Valente 8ac5c0a9a2 Revert "Timeuntil in UI (#1461)"
This reverts commit 221ff7b3af.
2025-02-12 22:19:18 +01:00
Carlos Valente 649f0af2cf refactor: reword error 2025-02-12 22:19:18 +01:00
Carlos Valente fd9b944ecf bump version to 3.11.0 2025-02-12 22:19:18 +01:00
Alex Christoffer Rasmussen 0b36608134 Catch xlsx error (#1487)
* catch potential xlsx errors

* clear worksheet errors when starting new connections
2025-02-12 13:38:18 +01:00
Carlos Valente bac11adeb1 feat: add autocomplete to osc arguments 2025-02-10 20:01:36 +01:00
Carlos Valente 31799f8fab fix: project created without extension 2025-02-10 07:39:05 +01:00
Carlos Valente af177060ff refactor: add next to source fields 2025-02-06 21:20:25 +01:00
Carlos Valente d26a3cd393 refactor: change order of operations 2025-02-06 21:20:25 +01:00
Carlos Valente 38a3165a79 refactor: larger settings panels 2025-02-04 22:35:17 +01:00
Carlos Valente 1715c19747 refactor: clarify empty text 2025-02-04 22:35:17 +01:00
Carlos Valente 6be6e5fd8d refactor: handle overflow in menulist 2025-02-04 22:35:17 +01:00
Carlos Valente 44b0cfd928 refactor: allow locking cuesheet and operator 2025-02-04 22:35:00 +01:00
Carlos Valente 3d50b8ce49 refactor: simplify navigation lock 2025-02-04 22:35:00 +01:00
Carlos Valente c3d66f5bd1 fix: maintain locked and token params 2025-02-01 21:34:29 +01:00
Carlos Valente d08787a6ba refactor: allow presets for op and cuesheet 2025-02-01 21:34:29 +01:00
Carlos Valente f0e6231338 refactor: simplify url preset logic 2025-02-01 21:34:29 +01:00
Carlos Valente 430bfb38f2 chore: test is typescript 2025-02-01 21:34:29 +01:00
Carlos Valente 60354f8ea3 fix: prevent non alphanumeric in pincode 2025-02-01 11:38:58 +01:00
Carlos Valente bc12531729 refactor: show multiline text in custom fields 2025-02-01 11:38:58 +01:00
Carlos Valente 0b8a694e4f refactor: allow overriding operator styles 2025-02-01 11:38:58 +01:00
Carlos Valente 73ada99596 fix: update time in block (#1474)
* fix: update time in block

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-01-31 17:20:02 +01:00
Alex Christoffer Rasmussen f509655d31 update types/node and osc-min (#1475) 2025-01-30 22:25:25 +01:00
Carlos Valente fb1bbd6d5c refactor: fix roll next day 2025-01-27 19:24:11 +01:00
Alex Christoffer Rasmussen 221ff7b3af Timeuntil in UI (#1461)
* cherry pick EventBlockChip

* pull data down throug components

* add comment

* cleanup css

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-27 16:39:32 +01:00
Carlos Valente 5d5b48aca6 bump version to 3.11.0-beta.1 2025-01-26 20:22:01 +01:00
Carlos Valente 0f57689750 feat: generate authenticated url (#1471)
* refactor: extract info component

* feat: generate authenticated url

* refactor: add companion select

* refactor: ensure behaviour across environments
2025-01-26 16:51:28 +01:00
Alex Christoffer Rasmussen d34280c03d More batch (#1468)
* batch updates in client

* rearange updating logic

* utilety type

* guard undefinde in affectsLoaded

* fix: pause state not saved to restore point
2025-01-26 13:14:24 +01:00
Carlos Valente bb1e9072fd refactor: apply param structure 2025-01-25 18:54:25 +01:00
Carlos Valente 4503d1e411 refactor: param sections are collapsible 2025-01-25 18:54:25 +01:00
Carlos Valente b4caec0644 refactor: use type imports 2025-01-24 17:06:50 +01:00
Carlos Valente 960f0322ad refactor: improve Esc handling in forms 2025-01-24 16:48:42 +01:00
Carlos Valente a12694b186 refactor: use inline elements 2025-01-24 16:48:42 +01:00
Carlos Valente a1d34c8f6c refactor: remove > and < comparisons 2025-01-24 13:53:17 +01:00
Carlos Valente 7f8dbbfa87 refactor: simplify string comparisons 2025-01-24 13:53:17 +01:00
Carlos Valente 39e012452b refactor: improve form ux 2025-01-24 13:53:17 +01:00
Carlos Valente c333117347 refactor: start with small runtimedata subset 2025-01-24 13:53:17 +01:00
Carlos Valente eb0e5e6420 refactor: handle boolean comparisons 2025-01-24 13:53:17 +01:00
Carlos Valente 8ba65fa2bd refactor: handle non numeric comparisons 2025-01-24 13:53:17 +01:00
Carlos Valente efe5ac16f2 chore: rename feature elements
rename automations > triggers
rename blueprints > automations

chore: add link to documentation
2025-01-24 13:53:17 +01:00
arc-alex f4f266dbd4 refactor: migrate to OSC min 2025-01-24 13:53:17 +01:00
Carlos Valente bc9345199b feat: filtering logic 2025-01-24 13:53:17 +01:00
Carlos Valente 2cc434b0e9 refactor: remove legacy service 2025-01-24 13:53:17 +01:00
Carlos Valente 1f71d4578c refactor: migrate triggers to new service 2025-01-24 13:53:17 +01:00
Carlos Valente 6c4d80158c feat: send osc 2025-01-24 13:53:17 +01:00
Carlos Valente ab4dd300ca feat: send http 2025-01-24 13:53:17 +01:00
Carlos Valente 8b84963e17 feat: automation UI
merge with automation service
2025-01-24 13:53:17 +01:00
Carlos Valente 05e61e7bf8 feat: automation service 2025-01-24 13:53:17 +01:00
Carlos Valente a8fe4c8e68 refactor: allow both http and https 2025-01-24 13:53:17 +01:00
Alex Christoffer Rasmussen d175c788c5 Batch client updates (#1448) 2025-01-24 13:52:58 +01:00
Carlos Valente f1f628a48e bump version to 3.10.5 2025-01-24 13:31:18 +01:00
Carlos Valente d58335d1a1 refactor: allow overriding timer type in view 2025-01-24 13:13:53 +01:00
Carlos Valente 78c2bd79b5 refactor: card content on preload 2025-01-24 13:13:53 +01:00
Carlos Valente 28452d1e38 refactor: allow overriding progress bar phases 2025-01-24 13:13:53 +01:00
Carlos Valente 46790441e0 refactor: review view styles 2025-01-24 13:13:53 +01:00
Carlos Valente 3f35b5e297 refactor: extract and simplify logic 2025-01-24 13:13:53 +01:00
Carlos Valente 49cae647d6 refactor: larger progress bar 2025-01-24 13:13:53 +01:00
Carlos Valente 90700c85e8 refactor: subscript clock 2025-01-24 13:13:53 +01:00
Carlos Valente 8fac23c078 refactor: make function private 2025-01-24 13:13:53 +01:00
Carlos Valente b5cf6c4bd1 chore: directory restructure 2025-01-24 13:13:53 +01:00
Carlos Valente 0ea00ff6be chore: remove unused code 2025-01-24 12:41:42 +01:00
Carlos Valente 30e290c320 refactor: remove unnecessary handler 2025-01-22 20:51:12 +01:00
Carlos Valente ac52b3f0c1 chore: use smaller dependency 2025-01-22 20:51:12 +01:00
Carlos Valente 8e3db6c559 refactor: remove dom dependencies 2025-01-22 20:51:12 +01:00
Carlos Valente 1d2013ad41 fix: show correct state for welcome option 2025-01-19 22:18:03 +01:00
Carlos Valente 9c910c079d bump version to 3.10.4 2025-01-19 20:04:52 +01:00
Carlos Valente 420cef8b0d chore: add html files to docker 2025-01-19 19:57:10 +01:00
Carlos Valente b4c7b84f92 refactor: handle trailing slash in URL preset 2025-01-19 19:57:10 +01:00
Carlos Valente 0450eb9169 refactor: set token from query params 2025-01-19 19:57:10 +01:00
Carlos Valente 7fe5223af9 feat: add auth to server requests 2025-01-19 19:57:10 +01:00
arc-alex ce8d534953 feat: optional welcome modal
welcome modal is persisted in app state

created endpoints to set the visibility

added UI to support feature

Co-authored-by: Carlos Valente
<34649812+cpvalente@users.noreply.github.com>
2025-01-18 20:24:46 +01:00
Carlos Valente 4a6fbc2a92 refactor: add timezone data to session 2025-01-17 20:05:56 +01:00
Carlos Valente 50ebcf4e14 refactor: review welcome modal design 2025-01-17 20:04:00 +01:00
Carlos Valente f0f7d1cede docs: add homebrew links 2025-01-17 20:02:44 +01:00
Carlos Valente d2bdb1ea03 docs: add links to sponsor 2025-01-17 20:02:44 +01:00
Carlos Valente 9be6febcda chore: upgrade playwright 2025-01-17 19:57:01 +01:00
Carlos Valente aa929fd0dc refactor: create utility for nested templates 2025-01-12 10:20:24 +01:00
Carlos Valente 7baa6a4ab9 refactor: create mock data utilities 2025-01-12 10:20:24 +01:00
Carlos Valente 082fe8b8d7 chore: write feature spec 2025-01-12 10:14:44 +01:00
367 changed files with 10760 additions and 5589 deletions
+1
View File
@@ -24,6 +24,7 @@ COPY --from=builder /app/apps/client/build ./client/
COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
COPY --from=builder /app/apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
+5
View File
@@ -2,6 +2,7 @@
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
![Homebrew Cask Version](https://img.shields.io/homebrew/cask/v/ontime)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
@@ -12,7 +13,11 @@
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.10.3",
"version": "3.13.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
-1
View File
@@ -15,7 +15,6 @@
],
"plugins": [
"react",
"testing-library",
"simple-import-sort",
"@tanstack/query"
],
+4 -8
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.10.3",
"version": "3.13.1",
"private": true,
"type": "module",
"dependencies": {
@@ -8,6 +8,7 @@
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
@@ -30,7 +31,7 @@
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"web-vitals": "^3.1.1",
"zustand": "^4.5.2"
"zustand": "^5.0.3"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
@@ -62,13 +63,9 @@
"devDependencies": {
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^14.1.1",
"@types/color": "^3.0.3",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "^4.2.1",
@@ -79,8 +76,7 @@
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"eslint-plugin-testing-library": "^5.9.1",
"jsdom": "^21.1.0",
"happy-dom": "^16.7.2",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "catalog:",
+7 -5
View File
@@ -22,14 +22,14 @@ const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
const TimerView = React.lazy(() => import('./views/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Public = React.lazy(() => import('./features/viewers/public/Public'));
const Public = React.lazy(() => import('./views/public/Public'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
@@ -44,6 +44,8 @@ const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const PCuesheet = withPreset(Cuesheet);
const POperator = withPreset(Operator);
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
@@ -155,8 +157,8 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<Operator />} />
<Route path='/cuesheet' element={<PCuesheet />} />
<Route path='/op' element={<POperator />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
+85
View File
@@ -0,0 +1,85 @@
import axios from 'axios';
import type {
Automation,
AutomationDTO,
AutomationOutput,
AutomationSettings,
Trigger,
TriggerDTO,
} from 'ontime-types';
import { apiEntryUrl } from './constants';
const automationsPath = `${apiEntryUrl}/automations`;
/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath);
return res.data;
}
/**
* HTTP request to edit the automations settings
*/
export async function editAutomationSettings(
automationSettings: Partial<AutomationSettings>,
): Promise<AutomationSettings> {
const res = await axios.post(automationsPath, automationSettings);
return res.data;
}
/**
* HTTP request to create a new automation trigger
*/
export async function addTrigger(trigger: TriggerDTO): Promise<Trigger> {
const res = await axios.post(`${automationsPath}/trigger`, trigger);
return res.data;
}
/**
* HTTP request to update an automation trigger
*/
export async function editTrigger(id: string, trigger: Trigger): Promise<Trigger> {
const res = await axios.put(`${automationsPath}/trigger/${id}`, trigger);
return res.data;
}
/**
* HTTP request to delete an automation trigger
*/
export function deleteTrigger(id: string): Promise<void> {
return axios.delete(`${automationsPath}/trigger/${id}`);
}
/**
* HTTP request to create a new automation
*/
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
const res = await axios.post(`${automationsPath}/automation`, automation);
return res.data;
}
/**
* HTTP request to update a automation
*/
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
return res.data;
}
/**
* HTTP request to delete a automation
*/
export function deleteAutomation(id: string): Promise<void> {
return axios.delete(`${automationsPath}/automation/${id}`);
}
/**
* HTTP request to test automation output
* The return is irrelevant as we care for the resolution of the promise
*/
export function testOutput(output: AutomationOutput): Promise<void> {
return axios.post(`${automationsPath}/test`, output);
}
+2 -2
View File
@@ -4,9 +4,8 @@ import { serverURL } from '../../externals';
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
export const APP_VERSION = ['appVersion'];
export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown'];
@@ -15,6 +14,7 @@ export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
+3 -2
View File
@@ -1,7 +1,8 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils';
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -42,7 +43,7 @@ export async function downloadCSV(fileName: string = 'rundown') {
const { project, rundown, customFields } = data;
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSV(sheetData);
const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`);
-21
View File
@@ -1,21 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { HttpSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const httpPath = `${apiEntryUrl}/http`;
/**
* HTTP request to retrieve http settings
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(httpPath);
return res.data;
}
/**
* HTTP request to mutate http settings
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(httpPath, data);
}
-21
View File
@@ -1,21 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { OSCSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const oscPath = `${apiEntryUrl}/osc`;
/**
* HTTP request to retrieve osc settings
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(oscPath);
return res.data;
}
/**
* HTTP request to mutate osc settings
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(oscPath, data);
}
+26
View File
@@ -0,0 +1,26 @@
import axios from 'axios';
import { OntimeReport } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, REPORT } from './constants';
export const reportUrl = `${apiEntryUrl}/report`;
/**
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(`${reportUrl}/`);
return res.data;
}
export async function deleteReport(id: string) {
await axios.delete(`${reportUrl}/${id}`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
export async function deleteAllReport() {
await axios.delete(`${reportUrl}/all`);
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
+13
View File
@@ -12,3 +12,16 @@ export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`);
return res.data;
}
/**
* HTTP request to get a pre-authenticated URL
*/
export async function generateUrl(
baseUrl: string,
path: string,
lock: boolean,
authenticate: boolean,
): Promise<string> {
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
return res.data.url;
}
+7
View File
@@ -19,3 +19,10 @@ export async function getSettings(): Promise<Settings> {
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
return axios.post(settingsPath, data);
}
/**
* Allows setting the welcome modal dialog state from the clients
*/
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
@@ -0,0 +1,26 @@
.subtle {
aspect-ratio: 1;
height: 2rem;
height: 2rem;
display: grid;
place-content: center;
background: $gray-1050;
color: $blue-400;
border: 1px solid transparent;
border-radius: 3px;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
@@ -0,0 +1,14 @@
import { ButtonHTMLAttributes } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss';
export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) {
const { className, children, ...buttonProps } = props;
return (
<button className={cx([style.subtle, className])} {...buttonProps}>
{children}
</button>
);
}
@@ -0,0 +1,31 @@
.textEntry {
grid-area: input;
display: flex;
align-items: center;
gap: 0.5rem;
color: $label-gray;
}
.inlineEntry {
display: grid;
grid-template-areas:
'label label'
'input btn';
grid-template-columns: 1fr auto;
column-gap: 0.5rem;
}
.redirect {
grid-area: btn;
}
.label {
color: $label-gray;
}
.link {
color: $label-gray;
font-size: $text-body-size;
text-decoration: underline;
text-underline-offset: 2px;
}
@@ -1,64 +1,126 @@
import { useState } from 'react';
import {
Button,
IconButton,
Input,
InputGroup,
InputLeftAddon,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
} from '@chakra-ui/react';
import { IoArrowForward } from '@react-icons/all-files/io5/IoArrowForward';
import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import Info from '../info/Info';
import style from './RedirectClientModal.module.scss';
interface RedirectClientModalProps {
id: string;
name?: string;
path?: string;
name: string;
currentPath: string;
origin: string;
isOpen: boolean;
onClose: () => void;
}
export function RedirectClientModal(props: RedirectClientModalProps) {
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
const { id, isOpen, name, currentPath, origin, onClose } = props;
const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/');
const { setRedirect } = setClientRemote;
const handleRedirect = () => {
if (path !== currentPath && path !== '') {
setRedirect({ target: id, redirect: path });
const handleRedirect = (newPath: string) => {
if (newPath === '/' || newPath === currentPath) {
return;
}
setRedirect({ target: id, redirect: newPath });
onClose();
};
const host = window.location.origin;
const canSubmit = path !== currentPath && path !== '';
const enabledPresets = data.filter((preset) => preset.enabled);
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent>
<ModalContent maxWidth='max(480px, 35vw)'>
<ModalHeader>Redirect: {name}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<InputGroup variant='ontime-filled' size='md'>
<InputLeftAddon>{host}</InputLeftAddon>
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
</InputGroup>
<Info>
Remotely redirect the client to a different URL. <br />
Either by selecting a URL Preset or entering a custom path.
<br />
<br />
<a href='/editor?settings=feature_settings__urlpresets' target='_blank' className={style.link}>
Manage URL Presets
</a>
</Info>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.textEntry}>
<Select
size='md'
variant='ontime'
isDisabled={enabledPresets.length === 0}
onChange={(event) => setSelected(event.target.value)}
>
<option value='/'>Select view or preset</option>
{navigatorConstants.map((view) => {
return (
<option key={view.url} value={`/${view.url}`}>
{view.label}
</option>
);
})}
{enabledPresets.map((preset) => {
return (
<option key={preset.pathAndParams} value={preset.pathAndParams}>
{`Preset: ${preset.alias}`}
</option>
);
})}
</Select>
<IconButton
variant='ontime-filled'
size='md'
aria-label='Redirect to preset'
className={style.redirect}
icon={<IoArrowForward />}
isDisabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
/>
</div>
</div>
<div className={style.inlineEntry}>
<span className={style.label}>Enter custom path</span>
<label className={style.textEntry}>
{origin}
<Input
variant='ontime-filled'
size='md'
placeholder='eg. /minimal?key=0000ffff'
value={path}
onChange={(event) => setPath(event.target.value)}
/>
</label>
<IconButton
variant='ontime-filled'
size='md'
aria-label='Redirect'
isDisabled={path === currentPath || path === ''}
className={style.redirect}
icon={<IoArrowForward />}
onClick={() => handleRedirect(path)}
/>
</div>
</ModalBody>
<ModalFooter>
<Button size='md' variant='ontime-subtle' onClick={onClose}>
Cancel
</Button>
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
Submit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
@@ -14,7 +14,6 @@ interface ExternalLinkProps {
export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props;
const classes = cx([style.link, inline ? style.inline : null]);
const handleClick = (event: MouseEvent) => {
event.preventDefault();
@@ -22,8 +21,14 @@ export default function ExternalLink(props: ExternalLinkProps) {
};
return (
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
{children} <IoOpenOutline />
<a
href='#!'
target='_blank'
rel='noreferrer'
className={cx([style.link, inline && style.inline])}
onClick={handleClick}
>
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
</a>
);
}
@@ -0,0 +1,21 @@
.infoLabel {
display: flex;
align-items: center;
gap: $element-spacing;
background-color: $gray-1200;
border-radius: 3px;
font-size: $text-body-size;
padding: 1rem;
.content {
color: $gray-200;
}
svg {
min-width: 1.5rem;
align-self: start;
font-size: 1.5rem;
color: $info-blue;
}
}
@@ -0,0 +1,21 @@
import { PropsWithChildren } from 'react';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import { cx } from '../../utils/styleUtils';
import style from './Info.module.scss';
interface InfoProps {
className?: string;
}
export default function Info(props: PropsWithChildren<InfoProps>) {
const { className, children } = props;
return (
<div className={cx([style.infoLabel, className])}>
<IoAlertCircle />
<div>{children}</div>
</div>
);
}
@@ -3,9 +3,9 @@ import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from '@react-icons/all-files/io5/IoEyedrop';
import { ViewSettings } from 'ontime-types';
import PopoverPicker from '../../../../common/components/input/popover-picker/PopoverPicker';
import { debounce } from '../../../../common/utils/debounce';
import { debounce } from '../../../utils/debounce';
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import PopoverPicker from '../popover-picker/PopoverPicker';
import style from './SwatchSelect.module.scss';
@@ -1,6 +1,6 @@
@use '../../../theme/viewerDefs' as *;
$progress-bar-size: 12px;
$progress-bar-size: 1.5rem;
$progress-bar-br: 3px;
.multiprogress-bar {
@@ -53,3 +53,6 @@ $progress-bar-br: 3px;
.multiprogress-bar__bg-danger {
flex-shrink: 0;
}
.multiprogress-bar__bg-danger--overtime {
background: var(--timer-overtime-color-override, $timer-finished-color);
}
@@ -1,6 +1,7 @@
import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils';
import './MultiPartProgressBar.scss';
@@ -13,6 +14,7 @@ interface MultiPartProgressBar {
danger?: MaybeNumber;
dangerColor: string;
hidden?: boolean;
hideOvertime?: boolean;
ignoreCssOverride?: boolean;
className?: string;
}
@@ -27,6 +29,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
danger,
dangerColor,
hidden,
hideOvertime,
ignoreCssOverride,
className = '',
} = props;
@@ -34,12 +37,17 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
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;
const showOvertime = isOvertime && !hideOvertime;
return (
<div
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
} ${className}`}
className={cx([
'multiprogress-bar',
hidden && 'multiprogress-bar--hidden',
ignoreCssOverride && 'multiprogress-bar--ignore-css-override',
className,
])}
>
{now !== null && (
<>
@@ -50,8 +58,8 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
className={cx(['multiprogress-bar__bg-danger', showOvertime && 'multiprogress-bar__bg-danger--overtime'])}
style={{ width: `${dangerWidth}%`, backgroundColor: showOvertime ? undefined : dangerColor }}
/>
</div>
<div className='multiprogress-bar__indicator'>
@@ -1,7 +1,8 @@
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss';
@@ -15,7 +16,7 @@ export default function FloatingNavigation(props: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity();
return (
<div className={`${style.buttonContainer} ${!isButtonShown ? style.hidden : ''}`}>
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
@@ -7,24 +7,38 @@ $icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 3rem;
.fadeable {
opacity: 1;
transition-property: opacity;
transition-duration: 0.3s;
&.hidden {
opacity: 0;
}
}
.lockIcon {
font-size: 1.5rem;
color: $active-indicator;
padding: 0.5em;
position: fixed;
left: 0;
top: 0;
z-index: 12;
}
.buttonContainer {
display: flex;
flex-direction: column;
row-gap: 1rem;
padding: 0.5em;
transition-property: opacity;
transition-duration: 0.3s;
opacity: 1;
position: fixed;
left: 0;
top: 0;
z-index: 12;
&.hidden {
opacity: 0;
}
}
.button {
@@ -38,16 +52,6 @@ $button-size: 3rem;
border-radius: 3px;
}
.lockIcon {
font-size: 1.5rem;
display: grid;
place-content: center;
border-radius: 3px;
background-color: transparent;
color: $active-indicator;
cursor: default;
}
.navButton {
@extend .button;
z-index: 3;
@@ -1,4 +1,4 @@
import { memo, PropsWithChildren, useRef } from 'react';
import { memo, PropsWithChildren } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom';
import {
@@ -19,7 +19,6 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost } from '../../../externals';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import { useElectronEvent } from '../../hooks/useElectronEvent';
import useInfo from '../../hooks-query/useInfo';
import { useClientStore } from '../../stores/clientStore';
@@ -48,12 +47,8 @@ function NavigationMenu(props: NavigationMenuProps) {
const { mirror, toggleMirror } = useViewOptionsStore();
const location = useLocation();
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => onClose);
return createPortal(
<div id='navigation-menu-portal' ref={menuRef}>
<div id='navigation-menu-portal'>
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
<DrawerOverlay />
@@ -1,6 +1,7 @@
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss';
@@ -8,8 +9,8 @@ export default function ViewLockedIcon() {
const isLockIconShown = useFadeOutOnInactivity();
return (
<div className={`${style.buttonContainer} ${!isLockIconShown ? style.hidden : ''}`}>
<IoLockClosedOutline className={style.lockIcon} />
<div className={cx([style.fadeable, style.lockIcon, !isLockIconShown && style.hidden])}>
<IoLockClosedOutline />
</div>
);
}
@@ -1,23 +1,20 @@
import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { memo } from 'react';
import { useDisclosure } from '@chakra-ui/react';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu';
import useViewEditor from './useViewEditor';
import ViewLockedIcon from './ViewLockedIcon';
function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
interface ViewNavigationMenuProps {
isLockable?: boolean;
}
function ViewNavigationMenu(props: ViewNavigationMenuProps) {
const { isLockable } = props;
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const isViewLocked = isStringBoolean(searchParams.get('locked'));
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
@@ -0,0 +1,26 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
interface EditorVisibilityOptions {
isLockable?: boolean;
}
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
const [searchParams, setSearchParams] = useSearchParams();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const isViewLocked = useMemo(() => {
if (!isLockable) {
return false;
}
return isStringBoolean(searchParams.get('locked'));
}, [isLockable, searchParams]);
return { showEditFormDrawer, isViewLocked };
}
@@ -9,10 +9,6 @@ $progress-bar-br: 3px;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
overflow: clip;
&--hidden {
display: none;
}
}
.progress-bar__indicator {
@@ -7,16 +7,15 @@ import './ProgressBar.scss';
interface ProgressBarProps {
current: MaybeNumber;
duration: MaybeNumber;
hidden?: boolean;
className?: string;
}
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, hidden, className = '' } = props;
const { current, duration, className } = props;
const progress = getProgress(current, duration);
return (
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
<div className={`progress-bar__bg ${className}`}>
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
</div>
);
@@ -1,76 +0,0 @@
@use '../../../theme/viewerDefs' as *;
.schedule {
width: 100%;
border-spacing: 50px;
.entry {
font-size: clamp(16px, 1.5vw, 24px);
.entry-colour {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
height: clamp(8px, 0.75vw, 12px);
width: clamp(8px, 0.75vw, 12px);
border-radius: 6px;
display: inline-block;
}
.entry-times {
font-family: $viewer-font-family;
color: var(--secondary-color-override, $viewer-secondary-color);
font-weight: 300;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 8px;
}
.entry-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entry-secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:not(:last-child) {
padding-bottom: 8px;
}
&--past {
color: var(--secondary-color-override, $viewer-secondary-color);
}
&--now {
.entry-title {
color: var(--accent-color-override, $accent-color);
font-weight: 600;
}
}
&.skip {
text-decoration: line-through;
}
}
}
.schedule-nav {
display: flex;
justify-content: flex-end;
.schedule-nav__item {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
width: 12px;
height: 12px;
border-radius: 6px;
margin-left: 8px;
&--selected {
background-color: var(--color-override, $viewer-color);
}
}
}
@@ -1,52 +0,0 @@
import { useSchedule } from './ScheduleContext';
import ScheduleItem from './ScheduleItem';
import './Schedule.scss';
interface ScheduleProps {
isProduction?: boolean;
className?: string;
}
export default function Schedule({ isProduction, className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
// TODO: design a nice placeholder for empty schedules
if (paginatedEvents?.length < 1) {
return null;
}
let selectedState: 'past' | 'now' | 'future' = 'past';
return (
<ul className={`schedule ${className}`}>
{paginatedEvents.map((event) => {
if (scheduleType === 'past' || scheduleType === 'future') {
selectedState = scheduleType;
} else {
if (event.id === selectedEventId) {
selectedState = 'now';
} else if (selectedState === 'now') {
selectedState = 'future';
}
}
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
return (
<ScheduleItem
key={event.id}
selected={selectedState}
timeStart={timeStart}
timeEnd={timeEnd}
title={event.title}
colour={isBackstage ? event.colour : ''}
backstageEvent={!event.isPublic}
skip={event.skip}
/>
);
})}
</ul>
);
}
@@ -1,102 +0,0 @@
import { createContext, PropsWithChildren, useContext, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent } from 'ontime-types';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import { useInterval } from '../../hooks/useInterval';
interface ScheduleContextState {
events: OntimeEvent[];
paginatedEvents: OntimeEvent[];
selectedEventId: string | null;
scheduleType: 'past' | 'now' | 'future';
numPages: number;
visiblePage: number;
isBackstage: boolean;
}
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
interface ScheduleProviderProps {
events: OntimeEvent[];
selectedEventId: string | null;
isBackstage?: boolean;
time?: number;
}
const numEventsPerPage = 8;
export const ScheduleProvider = ({
children,
events,
selectedEventId,
isBackstage = false,
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
const [visiblePage, setVisiblePage] = useState(0);
const [searchParams] = useSearchParams();
// look for overrides from views
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
const viewEvents = [...events];
if (hidePast) {
// we want to show the event after the next
viewEvents.splice(0, selectedEventIndex + 2);
selectedEventIndex = 0;
}
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
const eventStart = eventsPerPage * visiblePage;
const eventEnd = eventsPerPage * (visiblePage + 1);
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
const resolveScheduleType = () => {
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
return 'now';
}
if (selectedEventIndex > eventEnd) {
return 'past';
}
return 'future';
};
const scheduleType = resolveScheduleType();
// every SCROLL_TIME go to the next array
useInterval(() => {
if (stopCycle) {
setVisiblePage(0);
} else if (events.length > eventsPerPage) {
const next = (visiblePage + 1) % numPages;
setVisiblePage(next);
}
}, time * 1000);
return (
<ScheduleContext.Provider
value={{
events,
paginatedEvents,
selectedEventId,
scheduleType,
numPages,
visiblePage,
isBackstage,
}}
>
{children}
</ScheduleContext.Provider>
);
};
export const useSchedule = () => {
const context = useContext(ScheduleContext);
if (!context) {
throw new Error('useSchedule() can only be used inside a ScheduleContext');
}
return context;
};
@@ -1,43 +0,0 @@
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
import { formatTime } from '../../utils/time';
import './Schedule.scss';
const formatOptions = {
format12: 'hh:mm a',
format24: 'HH:mm',
};
interface ScheduleItemProps {
selected: 'past' | 'now' | 'future';
timeStart: number;
timeEnd: number;
title: string;
backstageEvent: boolean;
colour: string;
skip: boolean;
}
export default function ScheduleItem(props: ScheduleItemProps) {
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
const userColour = colour !== '' ? colour : '';
const selectStyle = `entry--${selected}`;
return (
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: userColour }} />
<div style={{ display: 'flex' }}>
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent ? '*' : ''}
</div>
</div>
<div className='entry-title'>{title}</div>
</li>
);
}
@@ -1,23 +0,0 @@
import { useSchedule } from './ScheduleContext';
import './Schedule.scss';
interface ScheduleNavProps {
className?: string;
}
export default function ScheduleNav({ className }: ScheduleNavProps) {
const { numPages, visiblePage } = useSchedule();
return (
<div className={`schedule-nav ${className}`}>
{numPages > 1 &&
[...Array(numPages).keys()].map((i) => (
<div
key={i}
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
/>
))}
</div>
);
}
@@ -5,7 +5,7 @@
.empty {
width: 100%;
opacity: 0.6;
opacity: 0.8;
}
.text {
@@ -1,18 +1,20 @@
import { CSSProperties } from 'react';
import EmptyImage from '../../../assets/images/empty.svg?react';
import { cx } from '../../utils/styleUtils';
import style from './Empty.module.scss';
interface EmptyProps {
text?: string;
style?: CSSProperties;
className?: string;
}
export default function Empty(props: EmptyProps) {
const { text, ...rest } = props;
const { text, className, ...rest } = props;
return (
<div className={style.emptyContainer} {...rest}>
<div className={cx([style.emptyContainer, className])} {...rest}>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
</div>
@@ -4,25 +4,26 @@
position: relative;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title-card__title,
.title-card__secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title-card__title {
.title-card__placeholder {
font-weight: 600;
font-size: clamp(1.5rem, 3vw, 3rem);
color: var(--color-override, $viewer-color);
font-size: $title-font-size;
line-height: 1.2em;
}
.title-card__title {
color: var(--color-override, $viewer-color);
padding-right: 1em;
}
.title-card__placeholder {
color: var(--label-color-override, $viewer-label-color);
}
.title-card__secondary {
font-size: clamp(1rem, 2vw, 2.25rem);
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
}
@@ -31,9 +32,8 @@
position: absolute;
right: 1rem;
top: 0.5rem;
font-size: clamp(1rem, 1.5vw, 1.5rem);
font-size: $timer-label-size;
color: var(--secondary-color-override, $viewer-secondary-color);
margin-left: auto;
text-transform: uppercase;
&--accent {
@@ -1,11 +1,12 @@
import { ForwardedRef, forwardRef } from 'react';
import { useTranslation } from '../../../translation/TranslationProvider';
import { cx } from '../../utils/styleUtils';
import './TitleCard.scss';
interface TitleCardProps {
title: string;
title?: string;
label?: 'now' | 'next';
secondary?: string;
className?: string;
@@ -18,9 +19,9 @@ const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivEl
const accent = label === 'now';
return (
<div className={`title-card ${className}`} ref={ref}>
<div className={cx(['title-card', className])} ref={ref}>
<span className='title-card__title'>{title}</span>
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
<span className={cx(['title-card__label', accent && 'title-card__label--accent'])}>
{label && getLocalizedString(`common.${label}`)}
</span>
<div className='title-card__secondary'>{secondary}</div>
@@ -0,0 +1,5 @@
.viewLogo {
max-width: 100%;
max-height: 75px;
display: block;
}
@@ -1,5 +1,7 @@
import { projectLogoPath } from '../../api/constants';
import './ViewLogo.scss';
interface ViewLogoProps {
name: string;
className: string;
@@ -7,5 +9,11 @@ interface ViewLogoProps {
export default function ViewLogo(props: ViewLogoProps) {
const { name, className } = props;
return <img src={`${projectLogoPath}/${name}`} className={className} />;
// we wrap the image in a div to help maintain the aspect ratio
return (
<div className={className}>
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
</div>
);
}
@@ -119,7 +119,7 @@ function MultiOption(props: EditFormMultiOptionProps) {
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
</MenuButton>
<MenuList>
<MenuList overflow='auto' maxHeight='200px'>
<MenuOptionGroup
type='checkbox'
value={paramState}
@@ -1,6 +1,6 @@
.drawerFooter {
display: flex;
justify-content: end;
justify-content: end;
gap: $section-spacing;
button {
@@ -8,56 +8,15 @@
}
}
.infoLabel {
.sectionList {
display: flex;
align-items: center;
gap: $element-spacing;
padding: 1rem;
flex-direction: column;
min-height: 100%;
gap: 2rem;
overflow-y: scroll;
padding-right: 0.5rem;
}
.info {
margin-bottom: 1rem;
background-color: $gray-1100;
border-radius: 2px;
font-size: $inner-section-text-size;
svg {
font-size: 1.5rem;
color: $info-blue;
}
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.section {
color: $ui-white;
font-size: 1rem;
&:not(:first-child) {
margin-top: 2rem;
}
}
.fieldSet {
display: flex;
padding: $section-spacing 0;
flex-direction: column;
gap: $element-spacing;
margin-left: 0.5rem;
}
.title {
font-size: $inner-section-text-size;
display: block;
width: 100%;
}
.description {
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
}
@@ -11,12 +11,12 @@ import {
DrawerOverlay,
useDisclosure,
} from '@chakra-ui/react';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import useViewSettings from '../../hooks-query/useViewSettings';
import Info from '../info/Info';
import ParamInput from './ParamInput';
import { isSection, ViewOption } from './types';
import { ViewOption } from './types';
import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss';
@@ -40,15 +40,15 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
// Convert paramFields to an object that contains default values
const defaultValues: Record<string, string> = {};
paramFields.forEach((option) => {
if (!isSection(option)) {
paramFields.forEach((section) => {
section.options.forEach((option) => {
defaultValues[option.id] = String(option.defaultValue);
}
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
});
});
// compare which values are different from the default values
@@ -133,35 +133,17 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
<DrawerBody>
{viewSettings.overrideStyles && (
<div className={style.infoLabel}>
<IoAlertCircle />
This view style is being modified by a custom CSS file. <br />
</div>
<Info className={style.info}>This view style is being modified by a custom CSS file.</Info>
)}
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{viewOptions.map((option) => {
if (isSection(option)) {
return (
<div key={option.section} className={style.section}>
{option.section}
</div>
);
}
if (option.type === 'persist') {
return null;
}
return (
<div key={option.title} className={style.fieldSet}>
<label className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput key={option.title} paramField={option} />
</label>
</div>
);
})}
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
{viewOptions.map((section) => (
<ViewParamsSection
key={section.title}
title={section.title}
collapsible={section.collapsible}
options={section.options}
/>
))}
</form>
</DrawerBody>
@@ -0,0 +1,50 @@
.section {
color: $ui-white;
font-size: 1rem;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
height: 2rem;
&.collapsible {
cursor: pointer;
&:hover {
border-bottom: 1px solid $white-10;
}
}
}
.label {
margin-top: $section-spacing;
font-size: $inner-section-text-size;
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title {
font-size: $inner-section-text-size;
display: block;
width: 100%;
}
.description {
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
}
.closed {
transition: rotate $transition-time-feedback;
rotate: 0deg;
}
.open {
transition: rotate $transition-time-feedback;
rotate: 180deg;
}
@@ -0,0 +1,54 @@
import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { cx } from '../../utils/styleUtils';
import ParamInput from './ParamInput';
import { type ParamField } from './types';
import style from './ViewParamsSection.module.scss';
interface ViewParamsSectionProps {
title: string;
collapsible?: boolean;
options: ParamField[];
}
export default function ViewParamsSection(props: ViewParamsSectionProps) {
const { title, collapsible, options } = props;
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const handleCollapse = () => {
if (collapsible) {
setCollapsed((prev) => !prev);
}
};
return (
<section className={style.section}>
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
{title}
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
</div>
{!collapsed && (
<>
{options.map((option) => {
if (option.type === 'persist') {
return null;
}
return (
<label key={option.title} className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
);
})}
</>
)}
</section>
);
}
@@ -0,0 +1,45 @@
import { CustomFields } from 'ontime-types';
import { makeOptionsFromCustomFields } from '../constants';
describe('makeOptionsFromCustomFields', () => {
const testCustomFields: CustomFields = {
field1: { label: 'Field 1', colour: 'red', type: 'string' },
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
};
it('creates a record of keys for the given custom fields', () => {
const result = makeOptionsFromCustomFields(testCustomFields);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
it('appends additional data', () => {
const additionalData = {
test1: 'test1',
test2: 'test2',
};
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
test1: 'test1',
test2: 'test2',
});
});
it('filtersImageTypes', () => {
const customFieldsWIthImage: CustomFields = {
...testCustomFields,
field3: { label: 'Field 3', colour: 'green', type: 'image' },
};
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
expect(result).toStrictEqual({
'custom-field1': 'Custom: Field 1',
'custom-field2': 'Custom: Field 2',
});
});
});
@@ -5,11 +5,17 @@ import type { ParamField } from './types';
export const makeOptionsFromCustomFields = (
customFields: CustomFields,
additionalOptions: Record<string, string> = {},
filterImageType = true,
) => {
return Object.entries(customFields).reduce((options, [key, value]) => {
const options = structuredClone(additionalOptions);
for (const [key, value] of Object.entries(customFields)) {
if (filterImageType && value.type === 'image') {
continue;
}
options[`custom-${key}`] = `Custom: ${value.label}`;
return options;
}, additionalOptions);
}
return options;
};
export const getTimeOption = (timeFormat: string): ParamField => {
@@ -38,3 +44,14 @@ export const showLeadingZeros: ParamField = {
type: 'boolean',
defaultValue: false,
};
export enum OptionTitle {
ClockOptions = 'Clock Options',
TimerOptions = 'Timer Options',
DataSources = 'Data sources',
ElementVisibility = 'Element visibility',
BehaviourOptions = 'View behaviour',
StyleOverride = 'View style override',
Animation = 'View animation',
Schedule = 'Schedule options',
}
@@ -1,6 +1,4 @@
type ParamSection = {
section: string;
};
import { OptionTitle } from './constants';
type BaseField = {
id: string;
@@ -28,11 +26,9 @@ type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
export type ParamField = BaseField &
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField);
export type ViewOption = ParamSection | ParamField;
/**
* Type assertion utility checks whether an entry is a section separator
*/
export function isSection(entry: ViewOption): entry is ParamSection {
return 'section' in entry;
}
export type ViewOption = {
title: OptionTitle;
options: ParamField[];
collapsible?: boolean;
};
@@ -0,0 +1,34 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { editAutomationSettings, getAutomationSettings } from '../api/automation';
import { AUTOMATION } from '../api/constants';
import { logAxiosError } from '../api/utils';
import { automationPlaceholderSettings } from '../models/AutomationSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: AUTOMATION,
queryFn: getAutomationSettings,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
}
export function useAutomationSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: editAutomationSettings,
onError: (error) => logAxiosError('Error saving Automation settings', error),
onSuccess: (data) => {
ontimeQueryClient.setQueryData(AUTOMATION, data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }),
});
return { isPending, mutateAsync };
}
@@ -1,34 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/constants';
import { getHTTP, postHTTP } from '../api/http';
import { logAxiosError } from '../api/utils';
import { httpPlaceholder } from '../models/Http';
import { ontimeQueryClient } from '../queryClient';
export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS,
queryFn: getHTTP,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
}
export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
});
return { isPending, mutateAsync };
}
@@ -1,34 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/constants';
import { getOSC, postOSC } from '../api/osc';
import { logAxiosError } from '../api/utils';
import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? oscPlaceholderSettings, status, isFetching, isError, refetch };
}
export function useOscSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postOSC,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isPending, mutateAsync };
}
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { OntimeReport } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT } from '../api/constants';
import { fetchReport } from '../api/report';
export default function useReport() {
const { data, refetch } = useQuery<OntimeReport>({
queryKey: REPORT,
queryFn: fetchReport,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
networkMode: 'always',
staleTime: MILLIS_PER_HOUR,
});
return { data: data ?? {}, refetch };
}
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types';
import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants';
@@ -11,6 +11,9 @@ import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 };
/**
* Normalised rundown data
*/
export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({
queryKey: RUNDOWN,
@@ -24,6 +27,10 @@ export default function useRundown() {
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
}
/**
* Provides access to a flat rundown
* built from the order and rundown fields
*/
export function useFlatRundown() {
const { data, status } = useRundown();
const { data: projectData } = useProjectData();
@@ -52,3 +59,15 @@ export function useFlatRundown() {
return { data: flatRunDown, status };
}
/**
* Provides access to a partial rundown based on a filter callback
*/
export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) {
const { data, status } = useFlatRundown();
const filteredData = useMemo(() => {
return data.filter(cb);
}, [data, cb]);
return { data: filteredData, status };
}
@@ -1,46 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import useClickOutside from '../useClickOutside';
describe('useClickOutside', () => {
let target: HTMLElement;
let anotherElement: HTMLElement;
beforeAll(() => {
target = global.document.createElement('div');
global.document.body.appendChild(target);
anotherElement = global.document.createElement('div');
global.document.body.appendChild(anotherElement);
});
it('should trigger clicking outside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
global.document.dispatchEvent(new Event('click'));
});
expect(callback).toHaveBeenCalled();
act(() => {
anotherElement.click();
});
expect(callback).toHaveBeenCalledTimes(2);
});
it('should not trigger clicking inside', () => {
const ref = { current: target };
const callback = vi.fn();
renderHook(() => useClickOutside(ref, callback));
act(() => {
target.click();
});
expect(callback).not.toHaveBeenCalled();
});
});
@@ -1,25 +0,0 @@
import { RefObject, useEffect } from 'react';
type ClickOutsideEventHandler = (event: MouseEvent) => void;
export default function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
callback: ClickOutsideEventHandler,
) {
useEffect(() => {
function handleClick(event: MouseEvent) {
const element = ref?.current;
// Do nothing if clicking ref's element or descendent element
if (!element || element.contains(event.target as Node)) {
return;
}
callback(event);
}
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, [ref, callback]);
}
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useShallow } from 'zustand/shallow';
import { useClientStore } from '../stores/clientStore';
import { socketSendJson } from '../utils/socket';
@@ -9,10 +10,12 @@ import { useIsOnline } from './useSocket';
export const useClientPath = () => {
const navigate = useNavigate();
const { pathname, search } = useLocation();
const { redirect, setRedirect } = useClientStore((store) => ({
redirect: store.redirect,
setRedirect: store.setRedirect,
}));
const { redirect, setRedirect } = useClientStore(
useShallow((store) => ({
redirect: store.redirect,
setRedirect: store.setRedirect,
})),
);
const isOnline = useIsOnline();
// notify of client path changes
@@ -55,6 +55,17 @@ export const useEventAction = () => {
defaultEndAction,
} = useEditorSettings();
const getEventById = useCallback(
(eventId: string) => {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.rundown) {
return;
}
return cachedRundown.rundown[eventId];
},
[queryClient],
);
/**
* Calls mutation to add new event
* @private
@@ -615,6 +626,7 @@ export const useEventAction = () => {
batchUpdateEvents,
deleteEvent,
deleteAllEvents,
getEventById,
reorderEvent,
swapEvents,
updateEvent,
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { throttle } from '../../common/utils/throttle';
import { throttle } from '../utils/throttle';
export const useFadeOutOnInactivity = () => {
const [isMouseMoved, setIsMouseMoved] = useState(false);
@@ -1,27 +0,0 @@
import { useEffect, useRef } from 'react';
/**
* @description utility hook to around setInterval
* @param callback
* @param delay
*/
export const useInterval = (callback, delay) => {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
/**
* @description function to be called
*/
function tick() {
savedCallback.current();
}
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
};
+103 -163
View File
@@ -3,73 +3,54 @@ import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ont
import { useRuntimeStore } from '../stores/runtime';
import { socketSendJson } from '../utils/socket';
const createSelector =
<T>(selector: (state: RuntimeStore) => T) =>
() =>
useRuntimeStore(selector);
export const setClientRemote = {
setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload),
setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload),
setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload),
};
export const useRundownEditor = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
nextEventId: state.eventNext?.id ?? null,
});
export const useRundownEditor = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
nextEventId: state.eventNext?.id ?? null,
}));
return useRuntimeStore(featureSelector);
};
export const useOperator = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
}));
export const useOperator = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
});
export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
blackout: state.message.timer.blackout,
blink: state.message.timer.blink,
secondarySource: state.message.timer.secondarySource,
}));
return useRuntimeStore(featureSelector);
};
export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
text: state.message.timer.text,
visible: state.message.timer.visible,
}));
export const useTimerViewControl = () => {
const featureSelector = (state: RuntimeStore) => ({
blackout: state.message.timer.blackout,
blink: state.message.timer.blink,
secondarySource: state.message.timer.secondarySource,
});
export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({
text: state.message.external,
visible: state.message.timer.secondarySource === 'external',
}));
return useRuntimeStore(featureSelector);
};
export const useTimerMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.timer.text,
visible: state.message.timer.visible,
});
return useRuntimeStore(featureSelector);
};
export const useExternalMessageInput = () => {
const featureSelector = (state: RuntimeStore) => ({
text: state.message.external,
visible: state.message.timer.secondarySource === 'external',
});
return useRuntimeStore(featureSelector);
};
export const useMessagePreview = () => {
const featureSelector = (state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
showAuxTimer: state.message.timer.secondarySource === 'aux',
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false,
});
return useRuntimeStore(featureSelector);
};
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
showAuxTimer: state.message.timer.secondarySource === 'aux',
showExternalMessage: state.message.timer.secondarySource === 'external' && Boolean(state.message.external),
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false,
}));
export const setMessage = {
timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }),
@@ -81,16 +62,12 @@ export const setMessage = {
socketSendJson('message', { timer: { secondarySource: payload } }),
};
export const usePlaybackControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
timerPhase: state.timer.phase,
});
return useRuntimeStore(featureSelector);
};
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
timerPhase: state.timer.phase,
}));
export const setPlayback = {
start: () => socketSendJson('start'),
@@ -114,32 +91,20 @@ export const setPlayback = {
},
};
export const useInfoPanel = () => {
const featureSelector = (state: RuntimeStore) => ({
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
});
export const useInfoPanel = createSelector((state: RuntimeStore) => ({
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
}));
return useRuntimeStore(featureSelector);
};
export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current);
export const useAuxTimerTime = () => {
const featureSelector = (state: RuntimeStore) => state.auxtimer1.current;
return useRuntimeStore(featureSelector);
};
export const useAuxTimerControl = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
});
return useRuntimeStore(featureSelector);
};
export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({
playback: state.auxtimer1.playback,
direction: state.auxtimer1.direction,
}));
export const setAuxTimer = {
start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }),
@@ -149,20 +114,13 @@ export const setAuxTimer = {
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
};
export const useSelectedEventId = () => {
const featureSelector = (state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null,
});
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null,
}));
return useRuntimeStore(featureSelector);
};
export const useCurrentBlockId = () => {
const featureSelector = (state: RuntimeStore) => ({
currentBlockId: state.currentBlock.block?.id ?? null,
});
return useRuntimeStore(featureSelector);
};
export const useCurrentBlockId = createSelector((state: RuntimeStore) => ({
currentBlockId: state.currentBlock.block?.id ?? null,
}));
export const setEventPlayback = {
loadEvent: (id: string) => socketSendJson('load', { id }),
@@ -171,81 +129,63 @@ export const setEventPlayback = {
pause: () => socketSendJson('pause'),
};
export const useTimer = () => {
const featureSelector = (state: RuntimeStore) => ({
...state.timer,
});
export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
return useRuntimeStore(featureSelector);
};
export const useClock = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
});
return useRuntimeStore(featureSelector);
};
export const useClock = createSelector((state: RuntimeStore) => ({
clock: state.clock,
}));
/** Used by the progress bar components */
export const useProgressData = () => {
const featureSelector = (state: RuntimeStore) => ({
current: state.timer.current,
duration: state.timer.duration,
timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null,
});
return useRuntimeStore(featureSelector);
};
export const useProgressData = createSelector((state: RuntimeStore) => ({
current: state.timer.current,
duration: state.timer.duration,
timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null,
}));
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
export const useRuntimeOverview = () => {
const featureSelector = (state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart,
actualStart: state.runtime.actualStart,
plannedEnd: state.runtime.plannedEnd,
expectedEnd: state.runtime.expectedEnd,
});
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart,
actualStart: state.runtime.actualStart,
plannedEnd: state.runtime.plannedEnd,
expectedEnd: state.runtime.expectedEnd,
}));
return useRuntimeStore(featureSelector);
};
export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
export const useRuntimePlaybackOverview = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
clock: state.clock,
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset,
numEvents: state.runtime.numEvents,
selectedEventIndex: state.runtime.selectedEventIndex,
offset: state.runtime.offset,
currentBlock: state.currentBlock,
}));
currentBlock: state.currentBlock,
});
export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
}));
return useRuntimeStore(featureSelector);
};
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset,
}));
export const useTimelineStatus = () => {
const featureSelector = (state: RuntimeStore) => ({
clock: state.clock,
offset: state.runtime.offset,
});
return useRuntimeStore(featureSelector);
};
export const usePing = () => {
const featureSelector = (state: RuntimeStore) => ({
ping: state.ping,
});
return useRuntimeStore(featureSelector);
};
export const usePing = createSelector((state: RuntimeStore) => ({
ping: state.ping,
}));
/** convert ping into a derived value which changes less often */
export const useIsOnline = () => {
export const useIsOnline = createSelector((state: RuntimeStore) => ({
isOnline: state.ping > 0,
}));
export const usePlayback = () => {
const featureSelector = (state: RuntimeStore) => ({
isOnline: state.ping > 0,
playback: state.timer.playback,
});
return useRuntimeStore(featureSelector);
@@ -0,0 +1,9 @@
import { AutomationSettings } from 'ontime-types';
export const automationPlaceholderSettings: AutomationSettings = {
enabledAutomations: false,
enabledOscIn: false,
oscPortIn: 8888,
triggers: [],
automations: {},
};
-6
View File
@@ -1,6 +0,0 @@
import { HttpSettings } from 'ontime-types';
export const httpPlaceholder: HttpSettings = {
enabledOut: false,
subscriptions: [],
};
-3
View File
@@ -1,11 +1,8 @@
import { GetInfo } from 'ontime-types';
import { oscPlaceholderSettings } from './OscSettings';
export const ontimePlaceholderInfo: GetInfo = {
networkInterfaces: [],
version: '2.0.0',
serverPort: 4001,
osc: oscPlaceholderSettings,
publicDir: '',
};
@@ -1,10 +0,0 @@
import { OSCSettings } from 'ontime-types';
export const oscPlaceholderSettings: OSCSettings = {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: [],
};
+12
View File
@@ -14,6 +14,18 @@ export const runtimeStore = createWithEqualityFn<RuntimeStore>(
export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
let batchStore: Partial<RuntimeStore> = {};
export function addToBatchUpdates<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
batchStore[key] = value;
}
export function flushBatchUpdates() {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...batchStore });
batchStore = {};
}
/**
* Allows patching a property of the runtime store
*/
@@ -0,0 +1,13 @@
import { makeCSVFromArrayOfArrays } from '../csv';
describe('makeCSVFromArrayOfArrays()', () => {
it('joins an array of arrays with commas and newlines', () => {
const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']];
expect(makeCSVFromArrayOfArrays(testdata)).toMatchInlineSnapshot(`
"field
after newline,after comma
,after empty
"
`);
});
});
@@ -0,0 +1,17 @@
import { linkToOtherHost } from '../linkUtils';
describe('linkToOTherHost', () => {
it('should handle electron links', () => {
const serverUrl = 'http://localhost:4001';
const baseUri = '';
const destination = linkToOtherHost('192.168.10.166', 'path', serverUrl, baseUri);
expect(destination).toBe('http://192.168.10.166:4001/path');
});
it('should handle ontime cloud links', () => {
const serverUrl = 'https://cloud.getontime.no/user-hash';
const baseUri = 'user-hash';
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
});
});
@@ -26,8 +26,8 @@ describe('simple tests for regex', () => {
});
test('startsWithHttp', () => {
const right = ['http://test'];
const wrong = ['https://test', 'testing', '123.0.1'];
const right = ['https://test', 'http://test'];
const wrong = ['testing', '123.0.1'];
right.forEach((t) => {
expect(startsWithHttp.test(t)).toBe(true);
@@ -1,103 +0,0 @@
import { resolvePath } from 'react-router-dom';
import { generateUrlFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
describe('A preset fails if incorrect', () => {
const testsToFail = [
// no empty
'',
// no https, http or www
'https://www.test.com',
'http://www.test.com',
'www.test.com',
// no hostname
'localhost/test',
'127.0.0.1/test',
'0.0.0.0/test',
// no editor
'editor',
'editor?test',
];
testsToFail.forEach((t) =>
it(`${t}`, () => {
expect(validateUrlPresetPath(t).isValid).toBeFalsy();
}),
);
});
describe('generateUrlFromPreset and getRouteFromPreset function', () => {
test('generate the expected url from an alias', () => {
const testData = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
const expected = [
{
url: '/timer?user=guest&alias=demopage',
},
];
expect(generateUrlFromPreset(testData[0])).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL is just the alias', () => {
const presets = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the alias
const location = resolvePath(presets[0].alias);
const expected = [
{
url: '/timer?user=guest&alias=demopage',
},
];
expect(getRouteFromPreset(location, presets, null)).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL the same url but with a change of params', () => {
const presets = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(presets[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', presets[0].alias); //
// update current alias with extra param
presets[0].pathAndParams += '&eventId=674';
const expected = [
{
url: '/timer?user=guest&eventId=674&alias=demopage',
},
];
expect(getRouteFromPreset(location, presets, urlSearchParams)).toStrictEqual(expected[0].url);
});
test('generate no url to redirect to when the current URL the same url', () => {
const presets = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(presets[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', presets[0].alias); //
expect(getRouteFromPreset(location, presets, urlSearchParams)).toBeNull();
});
});
@@ -0,0 +1,107 @@
import { resolvePath } from 'react-router-dom';
import { arePathsEquivalent, generatePathFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
describe('validateUrlPresetPaths()', () => {
test.each([
// no empty
'',
// no https, http or www
'https://www.test.com',
'http://www.test.com',
'www.test.com',
// no hostname
'localhost/test',
'127.0.0.1/test',
'0.0.0.0/test',
// no editor
'editor',
'editor?test',
])('flags known edge cases: %s', (t) => {
expect(validateUrlPresetPath(t).isValid).toBeFalsy();
});
});
describe('getRouteFromPreset()', () => {
const presets = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
it('checks if the current location matches an enabled preset', () => {
// we make the current location be the alias
const location = resolvePath('demopage');
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
});
it('returns null if the current location is the exact match of an unwrapped alias', () => {
// we make the current location be the alias
const location = resolvePath('/timer?user=guest&alias=demopage');
expect(getRouteFromPreset(location, presets)).toEqual(null);
});
it('returns a new destination if the current location is an out-of-date unwrapped alias', () => {
// we make the current location be the alias
const location = resolvePath('/timer?user=admin&alias=demopage');
expect(getRouteFromPreset(location, presets)).toEqual('timer?user=guest&alias=demopage');
});
it('checks if the current location contains an unwrapped preset', () => {
// we make the current location be the alias
const location = resolvePath('/timer?user=guest&alias=demopage');
expect(getRouteFromPreset(location, presets)).toEqual(null);
});
it('ignores a location that has no presets', () => {
// we make the current location be the alias
const location = resolvePath('/unknown');
expect(getRouteFromPreset(location, presets)).toEqual(null);
});
describe('handle url sharing edge cases', () => {
it('finds the correct preset when the url contains extra arguments', () => {
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy()
})
it('appends the feature params to the alias', () => {
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123')
})
});
});
describe('generatePathFromPreset()', () => {
test.each([
['timer?user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
['timer?user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
])('generates a path from a preset: %s', (path, alias, expected) => {
expect(generatePathFromPreset(path, alias, null, null)).toEqual(expected);
});
test('appends the feature params to the alias', () => {
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
});
});
describe('arePathsEquivalent()', () => {
it("checks whether the paths match", () => {
expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy();
expect(arePathsEquivalent('timer', 'timer')).toBeTruthy();
expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy();
})
it("checks whether the params match", () => {
expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
})
it("considers edge cases for the url sharing feature", () => {
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
})
});
+10
View File
@@ -0,0 +1,10 @@
import { stringify } from 'csv-stringify/browser/esm/sync';
/**
* @description Converts an array of arrays to a CSV file
* @param {string[][]} arrayOfArrays
* @return {string}
*/
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
return stringify(arrayOfArrays);
}
+17 -8
View File
@@ -18,23 +18,32 @@ export function openLink(url: string) {
/**
* Handles opening external links
* @param event
* @param location
* serverUrl and baseURI are used for testing
*/
export function handleLinks(event: MouseEvent, location: string) {
export function handleLinks(
event: MouseEvent,
location: string,
externalServerUrl: string = serverURL,
externalBaseURI: string = baseURI,
) {
// we handle the link manually
event.preventDefault();
const destination = new URL(serverURL);
destination.pathname = baseURI ? `${baseURI}/${location}` : location;
const destination = new URL(externalServerUrl);
destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
openLink(destination.toString());
}
export function linkToOtherHost(host: string, path?: string) {
const destination = new URL(serverURL);
export function linkToOtherHost(
host: string,
path?: string,
externalServerUrl: string = serverURL,
externalBaseURI: string = baseURI,
) {
const destination = new URL(externalServerUrl);
destination.hostname = host;
if (path) {
destination.pathname = baseURI ? `${baseURI}/${path}` : path;
destination.pathname = externalBaseURI ? `${externalBaseURI}/${path}` : path;
}
return destination.toString();
}
+1 -1
View File
@@ -5,7 +5,7 @@
export const isOnlyNumbers = /^\d+$/;
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
export const startsWithHttp = /^http:\/\//;
export const startsWithHttp = /^https?:\/\//;
export const startsWithSlash = /^\//;
export const isAlphanumeric = /^[a-z0-9]+$/i;
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
+42 -32
View File
@@ -1,7 +1,7 @@
import { Log, RundownCached, RuntimeStore } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, RUNDOWN, RUNTIME } from '../api/constants';
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient';
import {
@@ -14,7 +14,7 @@ import {
} from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger';
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime';
export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -34,12 +34,16 @@ export const connectSocket = () => {
hasConnected = true;
reconnectAttempts = 0;
socketSendJson('set-client-patch', {
type: 'ontime',
origin: window.location.origin,
path: window.location.pathname + window.location.search,
});
setOnlineStatus(true);
if (preferredClientName) {
socketSendJson('set-client-name', preferredClientName);
}
socketSendJson('set-client-type', 'ontime');
setOnlineStatus(true);
};
websocket.onclose = () => {
@@ -78,16 +82,14 @@ export const connectSocket = () => {
updateDevTools({ ping: offset });
break;
}
case 'client-id': {
if (typeof payload === 'string') {
setClientId(payload);
}
break;
}
case 'client-name': {
if (typeof payload === 'string') {
setClientName(payload);
case 'client': {
if (typeof payload === 'object' || payload !== null) {
if (payload.clientId && payload.clientName) {
setClientId(payload.clientId);
if (!preferredClientName) {
setClientName(payload.clientName);
}
}
}
break;
}
@@ -140,73 +142,81 @@ export const connectSocket = () => {
break;
}
case 'ontime-clock': {
patchRuntimeProperty('clock', payload);
addToBatchUpdates('clock', payload);
updateDevTools({ clock: payload });
break;
}
case 'ontime-timer': {
patchRuntimeProperty('timer', payload);
addToBatchUpdates('timer', payload);
updateDevTools({ timer: payload });
break;
}
case 'ontime-onAir': {
patchRuntimeProperty('onAir', payload);
addToBatchUpdates('onAir', payload);
updateDevTools({ onAir: payload });
break;
}
case 'ontime-message': {
patchRuntimeProperty('message', payload);
addToBatchUpdates('message', payload);
updateDevTools({ message: payload });
break;
}
case 'ontime-runtime': {
patchRuntimeProperty('runtime', payload);
addToBatchUpdates('runtime', payload);
updateDevTools({ runtime: payload });
break;
}
case 'ontime-eventNow': {
patchRuntimeProperty('eventNow', payload);
addToBatchUpdates('eventNow', payload);
updateDevTools({ eventNow: payload });
break;
}
case 'ontime-currentBlock': {
patchRuntimeProperty('currentBlock', payload);
addToBatchUpdates('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': {
patchRuntimeProperty('publicEventNow', payload);
addToBatchUpdates('publicEventNow', payload);
updateDevTools({ publicEventNow: payload });
break;
}
case 'ontime-eventNext': {
patchRuntimeProperty('eventNext', payload);
addToBatchUpdates('eventNext', payload);
updateDevTools({ eventNext: payload });
break;
}
case 'ontime-publicEventNext': {
patchRuntimeProperty('publicEventNext', payload);
addToBatchUpdates('publicEventNext', payload);
updateDevTools({ publicEventNext: payload });
break;
}
case 'ontime-auxtimer1': {
patchRuntimeProperty('auxtimer1', payload);
addToBatchUpdates('auxtimer1', payload);
updateDevTools({ auxtimer1: payload });
break;
}
case 'ontime-refetch': {
// the refetch message signals that the rundown has changed in the server side
const { revision, reload } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
const { reload, target } = payload;
if (reload) {
invalidateAllCaches();
} else if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
} else if (target === 'RUNDOWN') {
const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
}
} else if (target === 'REPORT') {
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
}
break;
}
case 'ontime-flush': {
flushBatchUpdates();
break;
}
}
} catch (_) {
// ignore unhandled
+87 -37
View File
@@ -1,13 +1,11 @@
import isEqual from 'react-fast-compare';
import { Location, resolvePath } from 'react-router-dom';
import { Path, resolvePath } from 'react-router-dom';
import { URLPreset } from 'ontime-types';
/**
* Validates a preset against defined parameters
* @param {string} preset
* @returns {{message: string, isValid: boolean}}
* Used in the context of form validation
*/
export const validateUrlPresetPath = (preset: string): { message: string; isValid: boolean } => {
export function validateUrlPresetPath(preset: string): { message: string; isValid: boolean } {
if (preset === '' || preset == null) {
return { isValid: false, message: 'Path cannot be empty' };
}
@@ -26,51 +24,103 @@ export const validateUrlPresetPath = (preset: string): { message: string; isVali
}
return { isValid: true, message: 'ok' };
};
}
/**
* Gets the URL to send a preset to
* @param location
* @param data
* @param searchParams
* Utility removes trailing slash from a string
*/
export const getRouteFromPreset = (location: Location, data: URLPreset[], searchParams: URLSearchParams) => {
const currentURL = location.pathname.substring(1);
function removeTrailingSlash(text: string): string {
return text.replace(/\/$/, '');
}
// we need to check if the whole url here is an alias, so we can redirect
const foundPreset = data.filter((d) => d.alias === currentURL && d.enabled)[0];
/**
* Checks whether the current location corresponds to a preset and returns the new path if necessary
*/
export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): string | null {
// current url is the pathname without the leading slash
const currentURL = location.pathname.substring(1);
const searchParams = new URLSearchParams(location.search);
// check if we have token or locked in the search params
const locked = searchParams.get('locked');
const token = searchParams.get('token');
// we need to check if the whole url is an alias
const foundPreset = urlPresets.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
if (foundPreset) {
return generateUrlFromPreset(foundPreset);
// if so, we can redirect to the preset path
return generatePathFromPreset(foundPreset.pathAndParams, foundPreset.alias, locked, token);
}
// if the current url is not an alias, we check if the alias is in the search parameters
const presetOnPage = searchParams.get('alias');
for (const d of data) {
if (presetOnPage) {
// if the alias fits the preset on this page, but the URL is different, we redirect user to the new URL
// if we have the same alias and its enabled and its not empty
if (d.alias !== '' && d.enabled && d.alias === presetOnPage) {
const newPath = resolvePath(d.pathAndParams);
const urlParams = new URLSearchParams(newPath.search);
urlParams.set('alias', d.alias);
// we confirm either the url parameters does not match or the url path doesnt
if (!isEqual(urlParams, searchParams) || newPath.pathname !== location.pathname) {
// we then redirect to the alias route, since the view listening to this alias has an outdated URL
return `${newPath.pathname}?${urlParams}`;
}
if (!presetOnPage) {
return null;
}
const currentPath = `${location.pathname}${location.search}`.substring(1);
for (const preset of urlPresets) {
// if the page has a known enabled alias, we check if we need to redirect
if (preset.alias === presetOnPage && preset.enabled) {
const newPath = generatePathFromPreset(preset.pathAndParams, preset.alias, locked, token);
if (!arePathsEquivalent(currentPath, newPath)) {
// if current path is out of date
// return new path so we can redirect
return newPath;
}
}
}
return null;
};
}
/**
* Generate URL from an preset
* @param presetData
* Handles generating a path and search parameters from a preset
*/
export const generateUrlFromPreset = (presetData: URLPreset) => {
const newPresetPath = resolvePath(presetData.pathAndParams);
const urlParams = new URLSearchParams(newPresetPath.search);
urlParams.set('alias', presetData.alias);
export function generatePathFromPreset(pathAndParams: string, alias: string, locked: string | null, token: string | null ): string {
const path = resolvePath(pathAndParams);
const searchParams = new URLSearchParams(path.search);
return `${newPresetPath.pathname}?${urlParams}`;
};
// save the alias so we have a reference to it being a preset and can update if necessary
searchParams.set('alias', alias);
// maintain params from the URL search feature
if (locked) {
searchParams.set('locked', locked);
}
if (token) {
searchParams.set('token', token);
}
// return path concatenated without the leading slash
return `${path.pathname}?${searchParams}`.substring(1);
}
/**
* Utility checks if two paths are equivalent
* Considers the edge cases for url sharing where a path may contain extra arguments from the alias
* - token
* - locked
*/
export function arePathsEquivalent(currentPath: string, newPath: string): boolean {
const currentUrl = new URL(currentPath, document.location.origin);
const newUrl = new URL(newPath, document.location.origin);
// check path
if (currentUrl.pathname !== newUrl.pathname) {
return false
}
// check search params
// if the params match, we dont need further checks
if (currentUrl.searchParams.toString() === newUrl.searchParams.toString()) {
return true
}
// if there is no match, we check the edge cases for the url sharing feature
currentUrl.searchParams.delete('token');
currentUrl.searchParams.delete('locked');
return currentUrl.searchParams.toString() === newUrl.searchParams.toString();
}
+4 -1
View File
@@ -12,6 +12,9 @@ export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export const githubSponsorUrl = 'https://github.com/sponsors/cpvalente';
export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
// resolve environment
export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production';
@@ -72,4 +75,4 @@ function resolveBaseURI(): string {
}
return base;
}
}
+7 -7
View File
@@ -1,6 +1,6 @@
/* eslint-disable react/display-name */
import { ComponentType, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import useUrlPresets from '../common/hooks-query/useUrlPresets';
import { getRouteFromPreset } from '../common/utils/urlPresets';
@@ -8,19 +8,19 @@ import { getRouteFromPreset } from '../common/utils/urlPresets';
const withPreset = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
const { data } = useUrlPresets();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
// navigate if is alias route
useEffect(() => {
if (!data) return;
const url = getRouteFromPreset(location, data, searchParams);
// navigate to this route if its not empty
if (url) {
navigate(url);
const destination = getRouteFromPreset(location, data);
// navigate to this destination if its not null
if (destination) {
navigate(destination);
}
}, [data, searchParams, navigate, location]);
}, [data, navigate, location]);
return <Component {...(props as P)} />;
};
@@ -3,10 +3,9 @@ import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel';
import ClientControlPanel from './panel/client-control-panel/ClientControlPanel';
import AutomationPanel from './panel/automations-panel/AutomationPanel';
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import GeneralPanel from './panel/general-panel/GeneralPanel';
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
import NetworkLogPanel from './panel/network-panel/NetworkLogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel';
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
@@ -30,10 +29,9 @@ export default function AppSettings() {
{panel === 'general' && <GeneralPanel location={location} />}
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'integrations' && <IntegrationsPanel location={location} />}
{panel === 'client_control' && <ClientControlPanel />}
{panel === 'about' && <AboutPanel />}
{panel === 'automation' && <AutomationPanel location={location} />}
{panel === 'network' && <NetworkLogPanel location={location} />}
{panel === 'about' && <AboutPanel />}
{panel === 'shutdown' && <ShutdownPanel />}
</PanelContent>
</ErrorBoundary>
@@ -10,7 +10,6 @@
flex-direction: column;
height: 100%;
width: 100%;
position: relative;
}
@@ -18,4 +17,5 @@
margin: 1rem;
overflow-y: auto;
flex-grow: 1;
padding-bottom: 300px;
}
@@ -26,19 +26,19 @@ $inner-padding: 1rem;
color: $gray-300;
}
.section, .indent {
.section,
.indent {
font-size: calc(1rem - 1px);
display: flex;
flex-direction: column;
gap: 1rem;
color: $ui-white;
}
.section {
position: relative;
margin-top: 2rem;
max-width: 800px;
max-width: 850px;
}
.indent {
@@ -111,6 +111,12 @@ $inner-padding: 1rem;
tr:nth-child(even) {
background-color: $white-1;
}
// allow highlighting table elements
tr[data-warn='true'],
td[data-warn='true'] {
background-color: $orange-1300;
}
}
.listGroup {
@@ -177,7 +183,7 @@ $inner-padding: 1rem;
color: $muted-gray;
td {
padding: 2rem;
padding-block: 5rem;
}
button {
@@ -185,23 +191,21 @@ $inner-padding: 1rem;
}
}
.inlineSiblings {
.inlineElements {
display: flex;
align-items: center;
gap: 1rem;
}
.empty {
background-color: $black-10;
text-align: center;
color: $muted-gray;
td {
padding: 2rem;
&.inner {
gap: 0.5rem;
}
button {
margin-top: 1rem;
&.component {
gap: 1rem;
}
&.start {
justify-content: flex-start;
}
&.end {
justify-content: flex-end;
}
}
@@ -1,4 +1,4 @@
import { HTMLAttributes, ReactNode } from 'react';
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -55,22 +55,29 @@ export function Card({ children, className, ...props }: { children: ReactNode }
}
export function Table({ className, children }: { className?: string; children: ReactNode }) {
const classes = cx([style.table, className]);
return (
<div className={style.pad}>
<table className={classes}>{children}</table>
<table className={cx([style.table, className])}>{children}</table>
</div>
);
}
export function TableEmpty({ handleClick }: { handleClick: () => void }) {
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
return (
<tr className={style.empty}>
<td colSpan={99}>
<div>No data yet</div>
<Button onClick={handleClick} variant='ontime-subtle' rightIcon={<IoAdd />} size='sm'>
New
</Button>
<div>{label ?? 'No data yet'}</div>
{handleClick && (
<Button
onClick={handleClick}
isDisabled={!handleClick}
variant='ontime-filled'
rightIcon={<IoAdd />}
size='sm'
>
New
</Button>
)}
</td>
</tr>
);
@@ -124,3 +131,22 @@ export function Loader({ isLoading }: { isLoading: boolean }) {
</div>
);
}
type AllowedInlineTags = 'div' | 'td';
type InlineProps<C extends AllowedInlineTags> = {
as?: C;
relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end';
className?: string;
};
export function InlineElements<C extends AllowedInlineTags = 'div'>({
children,
as,
relation = 'component',
align = 'start',
className,
}: PropsWithChildren<InlineProps<C>>) {
const Element = as ?? 'div';
return <Element className={cx([style.inlineElements, style[relation], style[align], className])}>{children}</Element>;
}
@@ -1,5 +1,12 @@
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
import {
buyMeACoffeeUrl,
discordUrl,
documentationUrl,
githubSponsorUrl,
githubUrl,
websiteUrl,
} from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion';
@@ -9,22 +16,29 @@ export default function AboutPanel() {
<>
<Panel.Header>About Ontime</Panel.Header>
<Panel.Section>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<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>
And 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>
</Panel.Section>
<Panel.Section>
<Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion />
</Panel.Section>
</>
);
}
@@ -0,0 +1,84 @@
.outerColumn {
gap: 2rem;
margin-bottom: 2rem;
h3 {
font-size: 1rem;
}
}
.innerColumn {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.matchRadio {
display: flex;
gap: 1rem;
}
.ruleSection {
display: flex;
flex-direction: column;
gap: 1rem;
}
.titleSection,
.filterSection,
.oscSection,
.httpSection,
.actionSection {
display: grid;
grid-gap: 0.5rem;
button {
align-self: flex-end;
}
}
.titleSection,
.ruleSection,
.filterSection,
.oscSection,
.httpSection,
.actionSection {
label,
div {
// we use the div as non-interactive placeholder for button cells
// it needs to match the size of the label element
font-size: calc(1rem - 3px);
}
label {
color: $label-gray;
}
}
.titleSection {
grid-template-columns: 1fr;
}
.filterSection {
grid-template-columns: 2fr 1fr 2fr auto;
}
.oscSection {
grid-template-columns: 9rem 5rem 3fr 4fr auto;
}
.httpSection {
grid-template-columns: 1fr auto;
}
.actionSection {
grid-template-columns: auto 1fr 1fr auto;
.test {
grid-column: -1;
}
}
.outputCard {
border-left: 0.25rem solid $gray-1200;
padding-left: 0.5rem;
}
@@ -0,0 +1,527 @@
import { useEffect, useMemo } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import {
Automation,
AutomationDTO,
HTTPOutput,
isHTTPOutput,
isOntimeAction,
isOSCOutput,
OntimeAction,
OSCOutput,
} from 'ontime-types';
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import Info from '../../../../common/components/info/Info';
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 TemplateInput from './template-input/TemplateInput';
import { isAutomation, makeFieldList } from './automationUtils';
import OntimeActionForm from './OntimeActionForm';
import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/integrations/#using-variables-in-integrations';
interface AutomationFormProps {
automation: Automation | AutomationDTO;
onClose: () => void;
}
export default function AutomationForm(props: AutomationFormProps) {
const { automation, onClose } = props;
const isEdit = isAutomation(automation);
const { data } = useCustomFields();
const { refetch } = useAutomationSettings();
const fieldList = useMemo(() => makeFieldList(data), [data]);
const {
control,
handleSubmit,
getValues,
register,
setError,
setFocus,
setValue,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<AutomationDTO>({
mode: 'onChange',
defaultValues: {
title: automation?.title ?? '',
filterRule: automation?.filterRule ?? 'all',
filters: automation?.filters ?? [],
outputs: automation?.outputs ?? [],
},
resetOptions: {
keepDirtyValues: true,
},
});
const {
fields: fieldFilters,
append: appendFilter,
remove: removeFilter,
} = useFieldArray({
name: 'filters',
control,
});
const {
fields: fieldOutputs,
append: appendOutput,
remove: removeOutput,
} = useFieldArray({
name: 'outputs',
control,
});
// give initial focus to the title field
useEffect(() => {
setFocus('title');
}, [setFocus]);
const handleAddNewFilter = () => {
appendFilter({ field: '', operator: 'equals', value: '' });
};
const handleAddNewOSCOutput = () => {
// @ts-expect-error -- we dont want to pass a port to the new object
appendOutput({ type: 'osc', targetIP: '', targetPort: undefined, address: '', args: '' });
};
const handleAddNewHTTPOutput = () => {
appendOutput({ type: 'http', url: '' });
};
const handleAddnewOntimeAction = () => {
// @ts-expect-error -- we dont want to choose an action
appendOutput({ type: 'ontime', action: undefined });
};
const handleTestOSCOutput = async (index: number) => {
try {
const values = getValues(`outputs.${index}`) as OSCOutput;
if (!values.targetIP || !values.targetPort || !values.address) {
return;
}
await testOutput({
type: 'osc',
targetIP: values.targetIP,
targetPort: values.targetPort,
address: values.address,
args: values.args,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
}
};
const handleTestHTTPOutput = async (index: number) => {
try {
const values = getValues(`outputs.${index}`) as HTTPOutput;
if (!values.url) {
return;
}
await testOutput({
type: 'http',
url: values.url,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
}
};
const handleTestOntimeAction = async (index: number) => {
try {
const values = getValues(`outputs.${index}`) as OntimeAction;
// NOTE: there is no meaningful validation to do here, we let the server deal with the data
await testOutput({
...values,
type: 'ontime',
});
} catch (_error) {
/** we dont handle errors here */
}
};
const onSubmit = async (values: AutomationDTO) => {
if (isAutomation(automation)) {
await handleEdit(automation.id, { id: automation.id, ...values });
} else {
await handleCreate(values);
}
refetch();
async function handleEdit(id: string, values: Automation) {
try {
await editAutomation(id, values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
}
async function handleCreate(values: AutomationDTO) {
try {
await addAutomation(values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
}
};
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<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' } })}
variant='ontime-filled'
size='sm'
placeholder='Load preset'
autoComplete='off'
/>
</label>
<Panel.Error>{errors.title?.message}</Panel.Error>
</div>
</div>
<div className={style.innerSection}>
<h3>Filters (optional)</h3>
<div className={style.ruleSection}>
<label>
Trigger outputs if
<Controller
name='filterRule'
control={control}
render={({ field }) => (
<RadioGroup {...field} size='sm' className={style.matchRadio} variant='ontime'>
<Radio value='all'>All filters pass</Radio>
<Radio value='any'>Any filter passes</Radio>
</RadioGroup>
)}
/>
</label>
{fieldFilters.map((field, index) => {
const key = `filters.${index}.field.${field.id}`;
return (
<div key={key} className={style.filterSection}>
<label>
Runtime data source
<Select
{...register(`filters.${index}.field`, { required: { value: true, message: 'Required field' } })}
size='sm'
variant='ontime'
>
<option selected hidden disabled value=''>
Event field
</option>
{fieldList.map(({ value, label }, localIndex) => {
const key = `filters.${index}.field.${localIndex}`;
return (
<option key={key} value={value}>
{label}
</option>
);
})}
</Select>
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
</label>
<label>
Matching condition
<Select
{...register(`filters.${index}.operator`, { required: { value: true, message: 'Required field' } })}
size='sm'
variant='ontime'
>
<option selected hidden disabled value=''>
Operator
</option>
<option value='equals'>equals</option>
<option value='not_equals'>not equals</option>
<option value='contains'>contains</option>
{/*
We dont currently offer a data source where these operators would make sense
<option value='greater_than'>greater than</option>
<option value='less_than'>less than</option>
*/}
</Select>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label>
<label>
Value to match
<Input
{...register(`filters.${index}.value`)}
variant='ontime-filled'
size='sm'
placeholder='<empty / no value>'
autoComplete='off'
/>
</label>
<div>
<span>&nbsp;</span>
<div>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
color='#FA5656' // $red-500
onClick={() => removeFilter(index)}
isDisabled={false}
isLoading={false}
/>
</div>
</div>
</div>
);
})}
<div>
<Button
variant='ontime-subtle'
size='sm'
type='submit'
rightIcon={<IoAdd />}
onClick={handleAddNewFilter}
isDisabled={false}
isLoading={false}
>
Add filter
</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.
<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;
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' },
})}
variant='ontime-filled'
size='sm'
placeholder='127.0.0.1'
autoComplete='off'
/>
<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' },
})}
variant='ontime-filled'
size='sm'
type='number'
maxLength={5}
placeholder='8000'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label>
Address
<Input
{...register(`outputs.${index}.address`)}
variant='ontime-filled'
size='sm'
placeholder='/cue/start'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput
{...register(`outputs.${index}.args`)}
value={output.args}
variant='ontime-filled'
size='sm'
placeholder='1'
/>
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
/>
</Panel.InlineElements>
</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://',
},
})}
variant='ontime-filled'
size='sm'
placeholder='http://127.0.0.1/start/1'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
/>
</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}
>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
/>
</Panel.InlineElements>
</OntimeActionForm>
</div>
);
}
// there should be no other output types
return null;
})}
<Panel.InlineElements relation='inner'>
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewOSCOutput}>
OSC
</Button>
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewHTTPOutput}>
HTTP
</Button>
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddnewOntimeAction}>
Ontime action
</Button>
</Panel.InlineElements>
</div>
<Panel.InlineElements align='end'>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
Cancel
</Button>
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -0,0 +1,39 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import AutomationSettingsForm from './AutomationSettingsForm';
import AutomationsList from './AutomationsList';
import TriggersList from './TriggersList';
export default function AutomationPanel({ location }: PanelBaseProps) {
const { data, status } = useAutomationSettings();
const settingsRef = useScrollIntoView<HTMLDivElement>('settings', location);
const triggersRef = useScrollIntoView<HTMLDivElement>('triggers', location);
const automationsRef = useScrollIntoView<HTMLDivElement>('automations', location);
const isLoading = status === 'pending';
return (
<>
<Panel.Header>Automation</Panel.Header>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<div ref={settingsRef}>
<AutomationSettingsForm
enabledAutomations={data.enabledAutomations}
enabledOscIn={data.enabledOscIn}
oscPortIn={data.oscPortIn}
/>
</div>
<div ref={automationsRef}>
<AutomationsList automations={data.automations} />
</div>
<div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} />
</div>
</Panel.Section>
</>
);
}
@@ -0,0 +1,164 @@
import { Controller, useForm } from 'react-hook-form';
import { Button, Input, Switch } from '@chakra-ui/react';
import { editAutomationSettings } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import Info from '../../../../common/components/info/Info';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/';
interface AutomationSettingsProps {
enabledAutomations: boolean;
enabledOscIn: boolean;
oscPortIn: number;
}
export default function AutomationSettingsForm(props: AutomationSettingsProps) {
const { enabledAutomations, enabledOscIn, oscPortIn } = props;
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<AutomationSettingsProps>({
mode: 'onChange',
defaultValues: { enabledAutomations, enabledOscIn, oscPortIn },
values: { enabledAutomations, enabledOscIn, oscPortIn },
resetOptions: {
keepDirtyValues: true,
},
});
const onSubmit = async (formData: AutomationSettingsProps) => {
try {
await editAutomationSettings(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
}
};
const onReset = () => {
reset({ enabledAutomations, enabledOscIn, oscPortIn });
};
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Card>
<Panel.SubHeader>
Automation settings
<Panel.InlineElements>
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='automation-settings-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Info>
<p>Control Ontime and share its data with external systems in your workflow.</p>
<p>- Automations allow Ontime to send its data on lifecycle triggers.</p>
<p>- OSC Input tells Ontime to listen to messages on the specific port.</p>
<br />
<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.Loader isLoading={false} />
<Panel.Title>Automation</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Enable automations'
description='Allow Ontime to send messages on lifecycle triggers'
error={errors.enabledAutomations?.message}
/>
<Controller
control={control}
name='enabledAutomations'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</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='OSC input'
description='Allow control of Ontime through OSC'
error={errors.enabledOscIn?.message}
/>
<Controller
control={control}
name='enabledOscIn'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</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'
width='5rem'
maxLength={5}
size='sm'
textAlign='right'
variant='ontime-filled'
type='number'
autoComplete='off'
{...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>
);
}
@@ -0,0 +1,130 @@
import { Fragment, useState } from 'react';
import { Button, IconButton } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { deleteAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
import AutomationForm from './AutomationForm';
const automationPlaceholder: AutomationDTO = {
title: '',
filterRule: 'all',
filters: [],
outputs: [],
};
interface AutomationsListProps {
automations: NormalisedAutomation;
}
export default function AutomationsList(props: AutomationsListProps) {
const { automations } = props;
const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDelete = async (id: string) => {
try {
setDeleteError(null);
await deleteAutomation(id);
} catch (error) {
setDeleteError(maybeAxiosError(error));
} finally {
refetch();
}
};
const arrayAutomations = Object.keys(automations);
return (
<Panel.Card>
<Panel.SubHeader>
Manage automations
<Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
type='submit'
isDisabled={Boolean(automationFormData)}
onClick={() => setAutomationFormData(automationPlaceholder)}
>
New
</Button>
</Panel.SubHeader>
<Panel.Divider />
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.Table>
<thead>
<tr>
<th style={{ width: '45%' }}>Title</th>
<th style={{ width: '15%' }}>Trigger rule</th>
<th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Outputs</th>
<th />
</tr>
</thead>
<tbody>
{arrayAutomations.length === 0 && (
<Panel.TableEmpty
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
/>
)}
{arrayAutomations.map((automationId) => {
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
size='sm'
variant='ontime-ghosted'
color='#e2e2e2' // $gray-200
icon={<IoPencil />}
aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])}
/>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDelete(automationId)}
/>
</Panel.InlineElements>
</tr>
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</Panel.Table>
</Panel.Card>
);
}
@@ -0,0 +1,110 @@
import { PropsWithChildren, useState } from 'react';
import { UseFormRegister, UseFormSetValue } from 'react-hook-form';
import { Input, Select } from '@chakra-ui/react';
import { AutomationDTO, OntimeAction } from 'ontime-types';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './AutomationForm.module.scss';
interface OntimeActionFormProps {
index: number;
register: UseFormRegister<AutomationDTO>;
rowErrors?: {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
};
value: OntimeAction['action'];
setValue: UseFormSetValue<AutomationDTO>;
}
export default function OntimeActionForm(props: PropsWithChildren<OntimeActionFormProps>) {
const { index, register, setValue, rowErrors, value, children } = props;
const [selectedAction, setSelectedAction] = useState<OntimeAction['action']>(value || 'aux-start');
const updateSelectedAction = (value: string) => {
setSelectedAction(value as OntimeAction['action']);
setValue(`outputs.${index}.action`, value as OntimeAction['action']);
};
return (
<div className={cx([style.actionSection, selectedAction && style[selectedAction]])}>
<input type='hidden' {...register(`outputs.${index}.action`)} value={selectedAction} />
<label>
Action
<Select
variant='ontime'
size='sm'
value={selectedAction}
onChange={(event) => updateSelectedAction(event.target.value)}
>
<option value='aux-start'>Auxiliary timer: start</option>
<option value='aux-pause'>Auxiliary timer: pause</option>
<option value='aux-stop'>Auxiliary timer: stop</option>
<option value='aux-set'>Auxiliary timer: set</option>
<option value='message-set'>Timer: timer message</option>
<option value='message-secondary'>Timer: timer secondary</option>
</Select>
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
</label>
{selectedAction === 'aux-set' && (
<label>
New time
<Input
{...register(`outputs.${index}.time`, {
required: { value: true, message: 'Required field' },
})}
variant='ontime-filled'
size='sm'
placeholder='eg: 10m5s'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.time?.message}</Panel.Error>
</label>
)}
{selectedAction === 'message-set' && (
<>
<label>
Text (leave empty for no change)
<Input
{...register(`outputs.${index}.text`)}
variant='ontime-filled'
size='sm'
placeholder='eg: Timer is finished'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label>
Visibility
<Select variant='ontime' size='sm' {...register(`outputs.${index}.visible`)}>
<option value=''>Untouched</option>
<option value='true'>Show</option>
<option value='false'>Hide</option>
</Select>
<Panel.Error>{rowErrors?.visible?.message}</Panel.Error>
</label>
</>
)}
{selectedAction === 'message-secondary' && (
<label>
Timer secondary source
<Select variant='ontime' size='sm' {...register(`outputs.${index}.secondarySource`)}>
<option value='aux'>Auxiliary timer</option>
<option value='external'>External</option>
<option value='null'>None</option>
</Select>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
)}
<div className={style.test}>{children}</div>
</div>
);
}
@@ -0,0 +1,139 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input, Select } from '@chakra-ui/react';
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
import { addTrigger, editTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils';
interface TriggerFormProps {
automations: NormalisedAutomation;
initialId?: string;
initialTitle?: string;
initialAutomationId?: string;
initialTrigger?: TimerLifeCycle;
onCancel: () => void;
postSubmit: () => void;
}
export default function TriggerForm(props: TriggerFormProps) {
const { automations, initialId, initialTitle, initialAutomationId, initialTrigger, onCancel, postSubmit } = props;
const {
handleSubmit,
register,
setFocus,
setError,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<TriggerDTO>({
defaultValues: {
title: initialTitle,
trigger: initialTrigger,
automationId: initialAutomationId,
},
resetOptions: {
keepDirtyValues: true,
},
});
// give initial focus to the title field
useEffect(() => {
setFocus('title');
// eslint-disable-next-line react-hooks/exhaustive-deps -- focus on mount
}, []);
const onSubmit = async (values: TriggerDTO) => {
// if we were passed an ID we are editing a Trigger
if (initialId) {
try {
await editTrigger(initialId, { id: initialId, ...values });
postSubmit();
} catch (error) {
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
}
return;
}
// otherwise we are creating a new automation
try {
await addTrigger(values);
postSubmit();
} catch (error) {
setError('root', { message: `Failed to save trigger ${maybeAxiosError(error)}` });
}
};
const automationSelect = Object.keys(automations).map((automation) => {
return {
value: automation,
label: automations[automation].title,
};
});
const canSubmit = isDirty && isValid;
return (
<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' } })}
size='sm'
variant='ontime-filled'
autoComplete='off'
defaultValue={initialTitle}
/>
<Panel.Error>{errors.title?.message}</Panel.Error>
</label>
<label>
Lifecycle trigger
<Select
size='sm'
variant='ontime'
defaultValue={initialTrigger}
{...register('trigger', { required: { value: true, message: 'Required field' } })}
>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
<Panel.Error>{errors.trigger?.message}</Panel.Error>
</label>
<label>
Automation title
<Select
size='sm'
variant='ontime'
defaultValue={initialAutomationId}
{...register('automationId', { required: { value: true, message: 'Required field' } })}
>
{automationSelect.map((automation) => (
<option key={automation.value} value={automation.value}>
{automation.label}
</option>
))}
</Select>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
<Panel.InlineElements align='end'>
<Button size='sm' variant='ontime-subtle' isDisabled={isSubmitting} onClick={onCancel}>
Cancel
</Button>
<Button type='submit' size='sm' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -0,0 +1,117 @@
import { Fragment, useMemo, useState } from 'react';
import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { NormalisedAutomation, Trigger } from 'ontime-types';
import { deleteTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
import { checkDuplicates } from './automationUtils';
import AutomationForm from './TriggerForm';
import TriggersListItem from './TriggersListItem';
interface TriggersListProps {
triggers: Trigger[];
automations: NormalisedAutomation;
}
export default function TriggersList(props: TriggersListProps) {
const { triggers, automations } = props;
const [showForm, setShowForm] = useState(false);
const { refetch } = useAutomationSettings();
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDelete = async (id: string) => {
try {
await deleteTrigger(id);
} catch (error) {
setDeleteError(maybeAxiosError(error));
} finally {
refetch();
}
};
const postSubmit = () => {
setShowForm(false);
refetch();
};
const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]);
// there is no point letting user creating a trigger if there are no automations
const canAdd = Object.keys(automations).length > 0;
return (
<Panel.Card>
<Panel.SubHeader>
Manage triggers
<Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
type='submit'
form='trigger-form'
isDisabled={!canAdd}
isLoading={false}
onClick={() => setShowForm(true)}
>
New
</Button>
</Panel.SubHeader>
<Panel.Divider />
<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>
);
}
@@ -0,0 +1,86 @@
import { useState } from 'react';
import { IconButton } from '@chakra-ui/react';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
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;
id: string;
title: string;
trigger: TimerLifeCycle;
automationId: string;
duplicate?: boolean;
handleDelete: () => void;
postSubmit: () => void;
}
export default function TriggersListItem(props: TriggersListItemProps) {
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}>
<Panel.InlineElements as='td' relation='inner'>
{duplicate && (
<IoWarningOutline
color='#FFBC56' // $orange-500
/>
)}
{title}
</Panel.InlineElements>
<td>
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
</td>
<td>
<Tag>{automations?.[automationId]?.title}</Tag>
</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#e2e2e2' // $gray-200
icon={<IoPencil />}
aria-label='Edit entry'
onClick={() => setIsEditing(true)}
/>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={handleDelete}
/>
</Panel.InlineElements>
</tr>
);
}
@@ -0,0 +1,24 @@
import { TimerLifeCycle, Trigger } from 'ontime-types';
import { checkDuplicates } from '../automationUtils';
describe('checkDuplicates', () => {
it('should return undefined if there are no duplicates', () => {
const triggers: Trigger[] = [
{ id: '1', title: 'First', trigger: TimerLifeCycle.onClock, automationId: '1' },
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onDanger, automationId: '2' },
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onLoad, automationId: '3' },
];
expect(checkDuplicates(triggers)).toBeUndefined();
});
it('should return list of titles of duplicates', () => {
const triggers: Trigger[] = [
{ id: '1', title: 'First', trigger: TimerLifeCycle.onClock, automationId: '1' },
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onDanger, automationId: '2' },
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onClock, automationId: '1' },
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onPause, automationId: '1' },
];
expect(checkDuplicates(triggers)).toStrictEqual([2]);
});
});
@@ -0,0 +1,85 @@
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
type CycleLabel = {
id: number;
label: string;
value: keyof typeof TimerLifeCycle;
};
export const cycles: CycleLabel[] = [
{ id: 1, label: 'On Load', value: 'onLoad' },
{ id: 2, label: 'On Start', value: 'onStart' },
{ id: 3, label: 'On Pause', value: 'onPause' },
{ id: 4, label: 'On Stop', value: 'onStop' },
{ id: 5, label: 'Every second', value: 'onClock' },
{ id: 6, label: 'On Timer Update', value: 'onUpdate' },
{ id: 7, label: 'On Finish', value: 'onFinish' },
{ id: 8, label: 'On Warning', value: 'onWarning' },
{ id: 9, label: 'On Danger', value: 'onDanger' },
];
/**
* We use this guard to find out if the form is receiving an existing automation or creating a DTO
* We do this by checking whether an ID has been generated
*/
export function isAutomation(automation: AutomationDTO | Automation): automation is Automation {
return Object.hasOwn(automation, 'id');
}
const staticSelectProperties = [
{ value: 'eventNow.id', label: 'ID' },
{ value: 'eventNow.title', label: 'Title' },
{ value: 'eventNow.cue', label: 'Cue' },
{ value: 'eventNow.countToEnd', label: 'Count to end' },
{ value: 'eventNow.isPublic', label: 'Is public' },
{ value: 'eventNow.note', label: 'Note' },
{ value: 'eventNow.colour', label: 'Colour' },
];
const staticNextSelectProperties = [
{ value: 'eventNow.id', label: 'Next ID' },
{ value: 'eventNow.title', label: 'Next Title' },
{ value: 'eventNow.cue', label: 'Next Cue' },
];
type SelectableField = {
value: string; // string encodes path in runtime state object
label: string;
};
export function makeFieldList(customFields: CustomFields): SelectableField[] {
return [
...staticSelectProperties,
...Object.entries(customFields).map(([key, { label }]) => ({
value: `eventNow.custom.${key}`,
label: `Custom: ${label}`,
})),
...staticNextSelectProperties,
...Object.entries(customFields).map(([key, { label }]) => ({
value: `eventNext.custom.${key}`,
label: `Next custom: ${label}`,
})),
];
}
/**
* We warn the user if they have created multiple links between the same automation and a trigger
*/
export function checkDuplicates(triggers: Trigger[]) {
const triggersMap: Record<string, string[]> = {};
const duplicates = [];
for (let i = 0; i < triggers.length; i++) {
const trigger = triggers[i];
if (!Object.hasOwn(triggersMap, trigger.trigger)) {
triggersMap[trigger.trigger] = [];
}
if (triggersMap[trigger.trigger].includes(trigger.automationId)) {
duplicates.push(i);
} else {
triggersMap[trigger.trigger].push(trigger.automationId);
}
}
return duplicates.length > 0 ? duplicates : undefined;
}
@@ -0,0 +1,29 @@
.wrapper {
position: relative;
}
.suggestions {
background: $gray-1250;
color: $ui-white;
position: absolute;
top: 100%;
left: 0;
width: 100%;
margin: 0;
z-index: 5;
padding-block: 0.25rem;
max-height: 200px;
overflow-y: auto;
color: $label-gray;
li {
padding: 0.25rem;
}
li:hover {
color: $ui-white;
background: $blue-700;
}
}
@@ -0,0 +1,70 @@
import { forwardRef, useMemo, useState } from 'react';
import { type InputProps, Input } from '@chakra-ui/react';
import { mergeRefs, useClickOutside } from '@mantine/hooks';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
import style from './TemplateInput.module.scss';
interface TemplateInputProps extends InputProps {}
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
const { value, onChange, ...rest } = props;
const { data } = useCustomFields();
const localRef = useClickOutside(() => setShowSuggestions(false));
const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data);
}, [data]);
const [inputValue, setInputValue] = useState(value || '');
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const updateSuggestions = (value: string) => {
const template = selectFromLastTemplate(value);
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
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));
}
onChange?.(event);
};
const handleSuggestion = (value: string) => {
setInputValue((prev) => {
const remaining = matchRemaining(prev as string, value);
return prev + remaining;
});
setShowSuggestions(false);
};
return (
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
<Input value={inputValue} {...rest} onChange={handleInputChange} autoComplete='off' autoCorrect='off' />
{showSuggestions && suggestions.length > 0 && (
<ul className={style.suggestions}>
{suggestions.map((suggestion) => (
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
{suggestion}
</li>
))}
</ul>
)}
</div>
);
});
export default TemplateInput;
@@ -0,0 +1,17 @@
import { matchRemaining } from '../templateInput.utils';
describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => {
expect(matchRemaining('te', 'test')).toBe('st');
expect(matchRemaining('{{{hum', '{{human}}')).toBe('an}}');
expect(matchRemaining('send {', '{{human}}')).toBe('{human}}');
expect(matchRemaining('{', '{{human}}')).toBe('{human}}');
expect(matchRemaining('{{', '{{human}}')).toBe('human}}');
});
it('should return an empty string if there are no matches or if it is complete', () => {
expect(matchRemaining('test', 'another')).toBe('');
expect(matchRemaining('test', 'test')).toBe('');
});
});
@@ -0,0 +1,115 @@
import { CustomFields } from 'ontime-types';
const humanConstants = [
'{{human.clock}}',
'{{human.duration}}',
'{{human.expectedEnd}}',
'{{human.runningTimer}}',
'{{human.elapsedTime}}',
'{{human.startedAt}}',
];
const staticAutocompleteOptions = [
'{{clock}}',
'{{timer.addedTime}}',
'{{timer.current}}',
'{{timer.duration}}',
'{{timer.elapsed}}',
'{{timer.expectedFinish}}',
'{{timer.finishedAt}}',
'{{timer.secondaryTimer}}',
'{{timer.startedAt}}',
'{{runtime.selectedEventIndex}}',
'{{runtime.numEvents}}',
'{{runtime.offset}}',
'{{runtime.plannedStart}}',
'{{runtime.plannedEnd}}',
'{{runtime.actualStart}}',
'{{runtime.expectedEnd}}',
'{{currentBlock.block}}',
'{{currentBlock.startedAt}}',
];
const eventStaticPropertiesNow = [
'{{eventNow.id}}',
'{{eventNow.cue}}',
'{{eventNow.title}}',
'{{eventNow.note}}',
'{{eventNow.timeStart}}',
'{{eventNow.timeEnd}}',
'{{eventNow.duration}}',
'{{eventNow.isPublic}}',
'{{eventNow.colour}}',
'{{eventNow.delay}}',
];
const eventStaticPropertiesNext = [
'{{eventNext.id}}',
'{{eventNext.cue}}',
'{{eventNext.title}}',
'{{eventNext.note}}',
'{{eventNext.timeStart}}',
'{{eventNext.timeEnd}}',
'{{eventNext.duration}}',
'{{eventNext.isPublic}}',
'{{eventNext.colour}}',
'{{eventNext.delay}}',
];
/**
* Creates a it of possible autocomplete suggestions
* Based on RuntimeState
* Appends the human readable variants to it
* It is manually kept in sync with the automation parseTemplate functions
*/
export function makeAutoCompleteList(customFields: CustomFields): string[] {
return [
...humanConstants,
...staticAutocompleteOptions,
...eventStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
];
}
/**
* Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st'
* @example matchRemaining('{{', '{{human}}') -> 'man}}'
*/
export function matchRemaining(a: string, b: string) {
if (a === b) {
return '';
}
// naive match assuming that a template will start with {{
if (a.endsWith('{{') && b.startsWith('{{')) {
return b.substring(2);
}
// naive match assuming that a template will start with {
if (a.endsWith('{') && b.startsWith('{{')) {
return b.substring(1);
}
for (let i = 0; i < b.length; i++) {
const searchString = b.substring(0, i + 1);
if (a.endsWith(searchString)) {
return b.substring(i + 1);
}
}
return '';
}
/**
* Selects the last starting template in a string
*/
export function selectFromLastTemplate(text: string) {
const lastBraceIndex = text.lastIndexOf('{{');
if (lastBraceIndex !== -1) {
return text.slice(lastBraceIndex);
}
return '';
}

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