Compare commits

...

175 Commits

Author SHA1 Message Date
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
Alex Christoffer Rasmussen e9a7cd0400 More rundown metadata (#1429)
* add dayOffset to OntimeEvent

* refactor: isNewLatest

* calculate totalDays

* refactor: getTimeFromPrevious

* add gap as dataset in OntimeEvent

* use in ui

* format overlap is just a simple text formatting, big test is not needed

* add new fields where needed

* update apply delay

* show nex day eaven if there is no gap

* create test

* use buildin day offset in timeline

* remove todo

* consistent naming

* make a calculateDayOffset util for rundownCache

* refactor: checkIsNextDay to use dayOffset

* spelling

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

* remove unneeded test

* remove todo

* update test db

* use data-testid

* spelling

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

* update comments

* remove null option

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-09 13:35:11 +01:00
Carlos Valente 9ba40c35a1 refactor: avoid matching file versions 2025-01-06 22:58:06 +01:00
Carlos Valente f94db06812 refactor: wider sheet id length 2025-01-06 22:58:06 +01:00
Carlos Valente 35424cfff2 bump version to 3.10.3 2025-01-06 20:33:39 +01:00
Alex Christoffer Rasmussen 13eadeb0af fix download (#1434)
* fix download

* update test db

* create download test
2025-01-06 20:26:06 +01:00
Carlos Valente 9cfed5a150 fix: issue with persistent script 2025-01-06 20:11:15 +01:00
Alex Christoffer Rasmussen a22c2d380d bump: v3.10.2 (#1431) 2025-01-06 14:08:36 +01:00
Alex Christoffer Rasmussen cd913fc144 small strict improvements (#1428)
* small `strict` improvements

* fix type

* use isObject
2025-01-06 14:07:36 +01:00
Alex Christoffer Rasmussen 743d7fa782 fix: validateLinkStart (#1430)
* fix validateLinkStart

* update comment

* use fallback

* cast to MaybeString

* update comment

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-06 13:12:03 +01:00
Carlos Valente 891c43ba77 refactor: countToEnd does not cause rundown recalculation 2025-01-05 15:18:28 +01:00
Carlos Valente fba20b889e refactor: only allow testing saved URLs 2025-01-05 15:17:53 +01:00
Carlos Valente bc4d913ba7 refactor: always show client path 2025-01-05 15:17:53 +01:00
Alex Christoffer Rasmussen 533f9798e8 convert to and from database type (#1410)
* create db event type

* dont use it yet

* convert to and from database type

* type needed in one place in client

* use flatMap to filter out unknow stuff

* leave revision in save file

* move to utils

* make test

* destructur entry

* simple test

* throw in rundownToDatabaseRundown

* clerify comment

* Refactor: avoid shadowing the rundown variable in rundownCache (#1409)


---------

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

* rename to DAO

* add simple edit test

* only the generate function clears the state state

* strict guard for custom fields

* fix small strict issues

* ensure deep copy

* safely set isStale

* todo is done

* small type error

* these functions can work on DAO entries

* only reat rundown as DAO on initial load and the cast immediately and esure all fields are present

* eslint comment

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

* fix spelling

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

* fix spelling

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

* comment not needed

* setIsStale

* remove DAO

* clean up commits

* remove unneeded async/await

* revert test

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-05 14:35:58 +01:00
Carlos Valente 1b62ed5f2d bump version to 3.10.1 2025-01-04 23:12:57 +01:00
Carlos Valente bd0c951da8 declare env variables 2025-01-04 23:12:57 +01:00
Carlos Valente 2d5b86808f refactor: cleanup pin styles 2025-01-04 16:39:47 +01:00
Carlos Valente c69bce3e89 refactor: cleanup picker implementations 2025-01-03 13:02:26 +01:00
Carlos Valente d62e314d19 bump version to 3.10.0 2025-01-03 12:56:37 +01:00
Shobhit-Nagpal dbd56522f6 refactor: swatch picker and inline colour picker 2025-01-02 21:30:41 +01:00
Shobhit-Nagpal 1ba48c6a6f refactor: inline colour picker 2025-01-02 21:30:41 +01:00
Carlos Valente f4f24da47b refactor: stop port change in dev 2025-01-02 14:18:19 +01:00
Carlos Valente c3609b2aed chore: extract middleware config 2025-01-02 14:18:19 +01:00
Carlos Valente 260d326989 chore: add noindex to app entry 2024-12-31 23:20:28 +01:00
Carlos Valente a21e32a097 refactor: expose indent style 2024-12-31 15:01:34 +01:00
Carlos Valente 617e4b579b refactor: introduce empty placeholder 2024-12-31 15:01:34 +01:00
Carlos Valente 44f65b2fcc refactor: extract utility 2024-12-31 13:04:00 +01:00
Carlos Valente 760de608e3 refactor: simplify logic 2024-12-31 13:01:22 +01:00
Carlos Valente 0dfef732bf refactor: prevent race conditions on init 2024-12-29 22:53:41 +01:00
Carlos Valente f65ec0da3c chore: upgrade dependencies 2024-12-29 20:02:56 +01:00
Shobhit Nagpal 6bbf8ce795 refactor: event editor color picker (#1400)
* refactor: event editor color picker

* refactor: throttle and debounce function signature
2024-12-27 10:57:48 +01:00
Carlos Valente 7f39d035fd refactor: show delayed times 2024-12-22 21:43:37 +01:00
Carlos Valente bdd2a62cee feat: allow editing times in cuesheet 2024-12-22 21:43:37 +01:00
Carlos Valente b38a7e5c92 refactor: allow submitting same value 2024-12-22 21:43:37 +01:00
Carlos Valente b173b5a8e8 chore: update manifest 2024-12-22 20:06:35 +01:00
Alex Christoffer Rasmussen 29243e3f6c detect EADDRINUSE on server start (#1348)
* detect `EADDRINUSE` on server start

* @ts-expect-error

* send portError to electron

* use escalateErrorFn

* dont move in docker

* allow errors to be sendt to electron UI

* Log after the new port is found

* Update comment

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

* update comment

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

* update comment

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

* rename to escalate

* extract `serverTryDesiredPort`

* type check server.address()

* request shutdown on `unrecoverable` `escalateError`

* combine network utils

* reject promise

* not unrecoverable by default

* unneeded assignment

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-12-22 16:23:47 +01:00
Carlos Valente 1af394cd49 chore: upgrade turbo 2024-12-21 22:46:07 +01:00
Carlos Valente 1c85265623 fix: section can only be selected if parent is 2024-12-21 13:56:23 +01:00
Carlos Valente 6199d0ad10 refactor: improve section style and composition 2024-12-20 21:24:34 +01:00
Carlos Valente 2f2c26ed00 chore: rename time-to-end to count-to-end 2024-12-20 21:24:34 +01:00
Carlos Valente c9ef2465f8 refactor: visibility options 2024-12-20 21:15:26 +01:00
Carlos Valente 430b72c1d0 fix: missing return when data is handled 2024-12-20 21:15:26 +01:00
Carlos Valente 88b114020b feat: edit duration in cuesheet 2024-12-20 21:15:26 +01:00
Carlos Valente c445c42f8a refactor: extract edit element in table 2024-12-20 21:15:26 +01:00
Carlos Valente 2fc4e6da16 refactor: extract utility type 2024-12-20 21:15:26 +01:00
Carlos Valente 4cbe25a845 refactor: simplify update functions 2024-12-20 21:15:26 +01:00
Carlos Valente ab6765b332 refactor: tweak table element styles 2024-12-20 21:15:26 +01:00
Carlos Valente fb38d82855 refactor: show cuesheet as long as we have data 2024-12-20 21:15:26 +01:00
Carlos Valente 1061ed2a0e refactor: improve fallback timer consistency 2024-12-20 15:27:57 +01:00
Carlos Valente 3d37f42fe0 refactor: tweak styling 2024-12-20 09:03:02 +01:00
Carlos Valente 1bdea8e436 refactor: edit cue field 2024-12-20 09:03:02 +01:00
Carlos Valente 2c25910863 refactor: add table actions 2024-12-20 09:03:02 +01:00
Carlos Valente 8f30c0a7df refactor: restructure element composition 2024-12-20 09:03:02 +01:00
Carlos Valente 67fa747aae refactor: extract cuesheet dnd 2024-12-20 09:03:02 +01:00
Carlos Valente 8116b169b2 refactor: generalise event editor 2024-12-20 09:03:02 +01:00
Carlos Valente ec823e0095 refactor: improve cuesheet composition
refactor: simplify placing events in rundown

m
2024-12-20 09:03:02 +01:00
Carlos Valente c7faab00a3 feat: create offline indicator 2024-12-19 22:15:48 +01:00
Alex Christoffer Rasmussen cfcf6a5684 extract isPlaybackActive to a global util function (#1393) 2024-12-18 12:44:48 +01:00
Carlos Valente df1cf7a96b feat: parse timeToEnd from excel 2024-12-16 21:59:38 +01:00
Carlos Valente 0f3feca7ab refactor: extract time-to-end 2024-12-16 21:59:38 +01:00
Carlos Valente 41fe213ebb refactor: settings in params 2024-12-16 15:13:49 +01:00
Carlos Valente e91d5f5cd9 refactor: always show settings 2024-12-16 15:13:49 +01:00
Carlos Valente bedab221d9 chore: dedupe lock file 2024-12-16 15:13:49 +01:00
Carlos Valente 97208c052a chore: upgrade dependencies 2024-12-16 15:13:49 +01:00
Alex Christoffer Rasmussen b048d0f88d Edit in cuesheet (#1372)
* pass on all event edits

* MakePublic

* trim value in cell

* edit notes

* title

* add key check

* don't log error

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

* checkbox

* refactor

* remove log

* fix test

* use row number to test value

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-12-16 14:27:25 +01:00
asharonbaltazar d8f7d4bba6 feat(ui): add warning in view params when styles are overriden (#1384)
* feat: add warning in view params when styles are overriden

* refactor: style tweaks

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-12-15 10:40:27 -05:00
Carlos Valente ed65a4db67 refactor: smaller settings 2024-12-15 11:04:15 +01:00
Carlos Valente 4a70bcfe64 chore: remove unused 2024-12-15 11:04:15 +01:00
Carlos Valente 89e68c3b0e fix: correct aria label 2024-12-15 11:04:15 +01:00
Carlos Valente 976dc9cbde refactor: tweak header style 2024-12-15 11:04:15 +01:00
Carlos Valente 3701ba5174 refactor: handle missing data 2024-12-15 11:04:15 +01:00
Carlos Valente acc6ba63ba refactor: keep drawer open on submit 2024-12-15 11:04:15 +01:00
Carlos Valente 6c5afcb8d9 refactor: improve empty state for pages 2024-12-15 09:55:52 +01:00
Carlos Valente d46dfcf82e refactor: improve redirect 2024-12-14 08:25:53 +01:00
Carlos Valente 4c6acc4013 refactor: improve active styles for navigation buttons 2024-12-13 11:24:03 +01:00
Carlos Valente 26a24449de refactor: prevent modal close on click outside 2024-12-13 10:40:16 +01:00
Carlos Valente a1fb642441 bump version to 3.9.5 2024-12-13 10:40:16 +01:00
Carlos Valente b64b154330 refactor: disable shutdown 2024-12-13 10:39:58 +01:00
Carlos Valente 5032cbf65a refactor: prevent instantiating unavailable services 2024-12-13 10:39:58 +01:00
Carlos Valente 466360f9d1 chore: note service limitations 2024-12-13 10:39:58 +01:00
Carlos Valente ef2ea9a1da fix: recover edit menu and shortcuts 2024-12-13 10:39:45 +01:00
Carlos Valente fb83f48752 refactor: maintain an isOnline flag in client 2024-12-13 10:39:30 +01:00
Carlos Valente c1d53b0e55 refactor: improve empty state for project info 2024-12-13 10:12:06 +01:00
Alex Christoffer Rasmussen 7915cc822d Dockersafe rename (#1370)
* add dockerSafeRename function

* replace all rename functions

* add explanation to function
2024-12-10 23:27:51 +01:00
Carlos Valente 7dbf64d100 chore: restructure directory 2024-12-10 15:53:53 +01:00
Carlos Valente 6ffbf2af9d chore: move files to new structure 2024-12-10 15:53:53 +01:00
Carlos Valente fa7ec623f1 bump version to 3.9.4 2024-12-08 08:52:18 +01:00
Carlos Valente bfb9b51073 fix: secure protocol resolution 2024-12-08 08:52:18 +01:00
Carlos Valente 8bc3f5a56c Fix links (#1366)
* chore: remove unused code

* bump version to 3.9.3

* chore: use constant links

* refactor: build links

* chore: ignore ontime-data
2024-12-06 16:48:35 +01:00
Carlos Valente acf1afb5e9 fix: prevent overflow 2024-12-06 14:54:41 +01:00
Carlos Valente d8e2a8d092 bump version to 3.9.2 2024-12-04 21:41:49 +01:00
Carlos Valente 8e587128a1 refactor: ensure element height 2024-12-04 21:41:49 +01:00
Alex Christoffer Rasmussen 607bc40673 create addTime function in simple timer (#1363)
* create addTime function in simple timer
2024-12-04 20:25:49 +01:00
Carlos Valente e6aa7404f7 chore: correct documentation 2024-12-04 19:49:49 +01:00
Carlos Valente d622a7738f refactor: remove deprecated field 2024-12-04 19:49:49 +01:00
Carlos Valente b9ba416366 refactor: add prefix to relative assets
refactor: add prefix to server entrypoints
refactor: add prefix to express router
refactor: add prefix to router basename
2024-12-04 19:49:49 +01:00
Carlos Valente b3ce247f54 bump version to 3.9.1 2024-12-02 14:45:57 +01:00
Carlos Valente 82a2660fbc fix: router prefix 2024-12-02 14:45:57 +01:00
404 changed files with 12257 additions and 8159 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }}
run: pnpm dist-mac
run: pnpm dist-mac --env-mode=loose
timeout-minutes: 60
- name: Release
+2
View File
@@ -28,6 +28,7 @@ test-results
playwright-report
/playwright/.cache/
automated-screenshots
e2e/tests/fixtures/tmp/*
# production
build/
@@ -39,6 +40,7 @@ dist/
# docker utils
ontime-db
ontime-external/
ontime-data/
# versioning file
**/ONTIME_VERSION.js
+1 -1
View File
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Build docker image from__ by running `docker build -t getontime/ontime .`
- __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands
+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.9.0",
"version": "3.11.0",
"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"
],
+26 -29
View File
@@ -1,31 +1,28 @@
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<base href="/">
<link rel='icon' href='/favicon.ico' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<meta name='theme-color' content='#101010' />
<meta name='ontime' content='ontime - time keeping for live events' />
<link rel='apple-touch-icon' href='/ontime-logo.png' />
<link
rel='icon'
type='image/png'
href='ontime-logo.png'
/>
<link rel='manifest' href='/site.webmanifest' />
<link rel='manifest' href='/manifest.json' />
<title>ontime</title>
<style>
body,
html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id='root'></div>
<script type='module' src='/src/index.tsx'></script>
</body>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="/" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" />
<link rel="apple-touch-icon" href="/ontime-logo.png" />
<link rel="icon" type="image/png" href="/ontime-logo.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="manifest" href="/manifest.json" />
<meta name="robots" content="noindex" />
<title>ontime</title>
<style>
body,
html {
background-color: rgba(0, 0, 0, 0) !important;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+8 -12
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.9.0",
"version": "3.11.0",
"private": true,
"type": "module",
"dependencies": {
@@ -8,15 +8,16 @@
"@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",
"@mantine/hooks": "^7.13.3",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^8.19.0",
"@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-devtools": "^5.17.9",
"@tanstack/react-table": "^8.11.3",
"@sentry/react": "^8.43.0",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.20.5",
"autosize": "^6.0.1",
"axios": "^1.2.0",
"color": "^4.2.3",
@@ -39,7 +40,7 @@
"build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "cross-env VITE_IS_CLOUD=true vite build",
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"test": "vitest",
@@ -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:",
+10 -1
View File
@@ -1 +1,10 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
{
"name": "",
"short_name": "",
"icons": [
{ "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
],
"theme_color": "#2B5ABC",
"background_color": "#101010",
"display": "standalone"
}
+2 -1
View File
@@ -11,6 +11,7 @@ import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
connectSocket();
@@ -19,7 +20,7 @@ function App() {
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
<BrowserRouter basename={baseURI}>
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
+6 -4
View File
@@ -19,10 +19,10 @@ import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
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'));
@@ -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);
}
+1 -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'];
+2 -2
View File
@@ -1,7 +1,7 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
import { makeCSV, makeTable } from '../../views/cuesheet/cuesheet.utils';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -12,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
* HTTP request to the current DB
*/
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { filename });
return axios.post(`${dbPath}/download`, { filename });
}
/**
-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);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types';
import { apiEntryUrl } from './constants';
@@ -16,7 +16,7 @@ export async function fetchNormalisedRundown(): Promise<RundownCached> {
/**
* HTTP request to post new event
*/
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> {
return axios.post(rundownPath, data);
}
+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 });
}
@@ -9,32 +9,23 @@ import style from './DelayIndicator.module.scss';
interface DelayIndicatorProps {
delayValue?: number;
tooltipPrefix?: string;
}
export default function DelayIndicator(props: DelayIndicatorProps) {
const { delayValue } = props;
const { delayValue, tooltipPrefix } = props;
if (typeof delayValue === 'number') {
if (delayValue < 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronDown />
</span>
</Tooltip>
);
}
if (delayValue > 0) {
return (
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
<span className={style.delaySymbol}>
<IoChevronUp />
</span>
</Tooltip>
);
}
if (typeof delayValue !== 'number' || delayValue === 0) {
return null;
}
return null;
const delayString = tooltipPrefix
? `${tooltipPrefix} ${millisToDelayString(delayValue)}`
: millisToDelayString(delayValue);
return (
<Tooltip openDelay={tooltipDelayFast} label={delayString}>
<span className={style.delaySymbol}>{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}</span>
</Tooltip>
);
}
@@ -0,0 +1,16 @@
.infoLabel {
display: flex;
align-items: center;
gap: $element-spacing;
padding: 1rem;
margin-bottom: 1rem;
background-color: $gray-1100;
border-radius: 2px;
font-size: $inner-section-text-size;
svg {
font-size: 1.5rem;
color: $info-blue;
}
}
@@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import style from './Info.module.scss';
export default function Info({ children }: PropsWithChildren) {
return (
<div className={style.infoLabel}>
<IoAlertCircle />
{children}
</div>
);
}
@@ -16,11 +16,11 @@ export default function Swatch(props: SwatchProps) {
const handleClick = () => {
onClick?.(color);
};
const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
if (!color) {
return (
<div className={`${classes} ${style.center}`} onClick={handleClick}>
<div className={classes} onClick={handleClick}>
<IoBan />
</div>
);
@@ -0,0 +1,60 @@
import { useCallback } from 'react';
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 { cx, getAccessibleColour } from '../../../utils/styleUtils';
import style from './SwatchSelect.module.scss';
interface SwatchPickerProps {
color: string;
isSelected?: boolean;
onChange: (name: string) => void;
alwaysDisplayColor?: boolean;
}
export default function SwatchPicker(props: SwatchPickerProps) {
const { color, onChange, isSelected, alwaysDisplayColor } = props;
const debouncedOnChange = useCallback(
debounce((newValue: string) => {
onChange(newValue);
}, 500),
[onChange],
);
const displayColor = alwaysDisplayColor || isSelected ? color : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={displayColor} onChange={debouncedOnChange}>
<div
className={cx([style.swatch, isSelected && style.selected, style.selectable])}
style={{ backgroundColor: displayColor }}
>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
export function SwatchPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
const displayColor = typeof value === 'string' ? value : '';
const { color: iconColor } = getAccessibleColour(displayColor);
return (
<PopoverPicker color={value as string} onChange={onChange}>
<div className={cx([style.swatch, style.selectable])} style={{ backgroundColor: displayColor }}>
<IoEyedrop color={iconColor} />
</div>
</PopoverPicker>
);
}
@@ -1,15 +1,18 @@
.list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem
gap: 0.5rem;
}
.swatch {
width: 2rem;
height: 2rem;
aspect-ratio: 1;
border-radius: 99px;
border: 2px solid $gray-1200;
color: $ui-white;
display: grid;
place-content: center;
&.selected {
border: 2px solid $blue-500;
@@ -17,11 +20,9 @@
&.selectable {
cursor: pointer;
&:hover {
border: 2px solid $blue-300;
}
}
}
.center {
display: grid;
place-content: center;
color: $ui-white;
}
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import Swatch from './Swatch';
import SwatchPicker from './SwatchPicker';
import style from './SwatchSelect.module.scss';
@@ -43,6 +44,7 @@ export default function SwatchSelect(props: ColourInputProps) {
{colours.map((colour) => (
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
))}
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
</div>
);
}
@@ -1,16 +1,20 @@
.swatch {
width: 24px;
height: 24px;
border-radius: 12px;
border: 1px solid white;
box-shadow: 0 0 0 1px $gray-300;
cursor: pointer;
.input {
color: $gray-200;
border: 1px solid transparent;
border-radius: 0 0 8px 8px;
background-color: $gray-1200;
padding: 0 0.5rem;
font-size: 1rem;
height: 2rem;
outline: none;
transition-property: box-shadow;
transition-duration: $transition-time-action;
&:hover {
background-color: $gray-1100;
}
&:focus {
background-color: $gray-1000;
color: $gray-50;
border: 1px solid $blue-500;
}
}
.swatch:hover {
box-shadow: 0 0 0 1px $blue-300;
}
@@ -1,34 +1,22 @@
import { HexAlphaColorPicker } from 'react-colorful';
import { useController, UseControllerProps } from 'react-hook-form';
import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import style from './PopoverPicker.module.scss';
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
const { name, control } = props;
const {
field: { onChange, value },
} = useController({ control, name });
return <PopoverPicker color={value as string} onChange={onChange} />;
}
interface PopoverPickerProps {
color: string;
onChange: (color: string) => void;
}
export default function PopoverPicker(props: PopoverPickerProps) {
const { color, onChange } = props;
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
const { color, onChange, children } = props;
return (
<Popover>
<PopoverTrigger>
<div className={style.swatch} style={{ backgroundColor: color }} />
</PopoverTrigger>
<PopoverContent style={{ width: 'auto' }}>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
<HexAlphaColorPicker color={color} onChange={onChange} />
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
</PopoverContent>
</Popover>
);
@@ -15,6 +15,8 @@ export default function useReactiveTextInput(
options?: {
submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean;
onCancelUpdate?: () => void;
allowSubmitSameValue?: boolean;
},
): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText);
@@ -47,7 +49,9 @@ export default function useReactiveTextInput(
const handleSubmit = useCallback(
(valueToSubmit: string) => {
// No need to update if it hasn't changed
if (valueToSubmit !== initialText) {
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
options?.onCancelUpdate?.();
} else {
const cleanVal = valueToSubmit.trim();
submitCallback(cleanVal);
if (cleanVal !== valueToSubmit) {
@@ -56,7 +60,7 @@ export default function useReactiveTextInput(
}
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
},
[initialText, ref, submitCallback],
[initialText, options, ref, submitCallback],
);
/**
@@ -66,8 +70,9 @@ export default function useReactiveTextInput(
const handleEscape = useCallback(() => {
// No need to update if it hasn't changed
setText(initialText);
options?.onCancelUpdate?.();
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
}, [initialText, ref]);
}, [initialText, options, ref]);
const keyHandler = useMemo(() => {
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
@@ -55,6 +55,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
// we dont know the values in the rundown, escalate to handler
if (newValue.startsWith('p') || newValue.startsWith('+')) {
submitHandler(name, newValue);
return true;
}
const valueInMillis = parseUserTime(newValue);
@@ -2,6 +2,8 @@ $input-delayed-border-color: $ontime-delay-text;
.timeInput {
border: 1px solid transparent;
color: $label-gray;
&.delayed {
border: 1px solid $input-delayed-border-color;
}
@@ -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'>
@@ -2,6 +2,7 @@ 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 { 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 {
@@ -17,15 +17,14 @@ import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { isLocalhost, serverPort } from '../../../externals';
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';
import { useViewOptionsStore } from '../../stores/viewOptions';
import { isKeyEnter } from '../../utils/keyEvent';
import { handleLinks, openLink } from '../../utils/linkUtils';
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
import { cx } from '../../utils/styleUtils';
import { RenameClientModal } from '../client-modal/RenameClientModal';
import CopyTag from '../copy-tag/CopyTag';
@@ -45,15 +44,11 @@ function NavigationMenu(props: NavigationMenuProps) {
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
const { fullscreen, toggle } = useFullscreen();
const { toggleMirror } = useViewOptionsStore();
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 />
@@ -65,7 +60,7 @@ function NavigationMenu(props: NavigationMenuProps) {
<DrawerBody padding={0}>
<div className={style.buttonsContainer}>
<div
className={style.link}
className={cx([style.link, fullscreen && style.current])}
tabIndex={0}
role='button'
onClick={toggle}
@@ -77,7 +72,7 @@ function NavigationMenu(props: NavigationMenuProps) {
{fullscreen ? <IoContract /> : <IoExpand />}
</div>
<div
className={style.link}
className={cx([style.link, mirror && style.current])}
tabIndex={0}
role='button'
onClick={() => toggleMirror()}
@@ -154,7 +149,8 @@ function OtherAddresses(props: OtherAddressesProps) {
return null;
}
const address = `http://${nif.address}:${serverPort}${currentLocation}`;
const address = linkToOtherHost(nif.address, currentLocation);
return (
<CopyTag
key={nif.name}
@@ -1,6 +1,7 @@
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { useFadeOutOnInactivity } from '../../../common/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 };
}
@@ -1,9 +1,13 @@
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 25vh;
background: $ui-black;
color: $ontime-color;
font-weight: 200;
font-size: 3rem;
@@ -42,4 +46,4 @@
to {
background: rgba($red-500, 0);
}
}
}
@@ -0,0 +1,20 @@
@use '../../../theme/viewerDefs' as *;
/* share the same style as a page layout */
.page {
margin: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5rem;
}
@@ -0,0 +1,20 @@
import { CSSProperties } from 'react';
import Empty from './Empty';
import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
style?: CSSProperties;
}
export default function EmptyPage(props: EmptyPageProps) {
const { text, ...rest } = props;
return (
<div className={style.page}>
<Empty text={text} {...rest} />
</div>
);
}
@@ -1,10 +1,12 @@
@use '../../../theme/viewerDefs' as *;
$title-primary-size: clamp(1.5rem, 3vw, 3rem);
$title-secondary-size: clamp(1rem, 2vw, 2.25rem);
.title-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title-card__title,
@@ -16,13 +18,13 @@
.title-card__title {
font-weight: 600;
font-size: clamp(1.5rem, 3vw, 3rem);
font-size: $title-primary-size;
color: var(--color-override, $viewer-color);
line-height: 1.2em;
}
.title-card__secondary {
font-size: clamp(1rem, 2vw, 2.25rem);
font-size: $title-secondary-size;
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
}
@@ -31,7 +33,7 @@
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;
@@ -5,7 +5,7 @@ import { useTranslation } from '../../../translation/TranslationProvider';
import './TitleCard.scss';
interface TitleCardProps {
title: string;
title?: string;
label?: 'now' | 'next';
secondary?: string;
className?: string;
@@ -1,6 +1,6 @@
import { useState } from 'react';
import PopoverPicker from '../input/popover-picker/PopoverPicker';
import SwatchPicker from '../input/colour-input/SwatchPicker';
import style from './InlineColourPicker.module.scss';
@@ -20,13 +20,10 @@ export default function InlineColourPicker(props: InlineColourPickerProps) {
const { name, value } = props;
const [colour, setColour] = useState(() => ensureHex(value));
const debouncedChange = (value: string) => {
setColour(value);
};
return (
<div className={style.inline}>
<PopoverPicker color={colour} onChange={debouncedChange} />
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
<span>{colour}</span>
<input type='hidden' name={name} value={colour} />
</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}
@@ -8,39 +8,11 @@
}
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
.sectionList {
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;
min-height: 100%;
gap: 2rem;
overflow-y: scroll;
padding-right: 0.5rem;
}
@@ -12,8 +12,11 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import ParamInput from './ParamInput';
import { isSection, ViewOption } from './types';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import Info from '../info/Info';
import { ViewOption } from './types';
import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss';
@@ -37,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
@@ -87,6 +90,8 @@ interface EditFormDrawerProps {
// TODO: this is a good candidate for memoisation, but needs the paramFields to be stable
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, onClose, onOpen } = useDisclosure();
useEffect(() => {
@@ -115,8 +120,6 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams);
onClose();
};
return (
@@ -129,30 +132,16 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
</DrawerHeader>
<DrawerBody>
<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>
);
})}
{viewSettings.overrideStyles && <Info>This view style is being modified by a custom CSS file.</Info>}
<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>
);
}
@@ -38,3 +38,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 };
}
@@ -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]);
}
+11 -4
View File
@@ -4,16 +4,23 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { useClientStore } from '../stores/clientStore';
import { socketSendJson } from '../utils/socket';
import { useIsOnline } from './useSocket';
export const useClientPath = () => {
const navigate = useNavigate();
const { pathname, search } = useLocation();
const redirect = useClientStore((store) => store.redirect);
const setRedirect = useClientStore((store) => store.setRedirect);
const { redirect, setRedirect } = useClientStore((store) => ({
redirect: store.redirect,
setRedirect: store.setRedirect,
}));
const isOnline = useIsOnline();
// notify of client path changes
useEffect(() => {
if (!isOnline) return;
socketSendJson('set-client-path', pathname + search);
}, [pathname, search]);
}, [pathname, search, isOnline]);
// navigate to new path when received from server
useEffect(() => {
@@ -26,7 +33,7 @@ export const useClientPath = () => {
// navigate if there is a path change
if (redirect !== pathname + search) {
navigate(redirect);
navigate(redirect, { replace: true });
}
}, [navigate, pathname, redirect, search, setRedirect]);
};
+95 -48
View File
@@ -1,6 +1,16 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
import {
isOntimeEvent,
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundownEntry,
RundownCached,
TimeField,
TimeStrategy,
TransientEventPayload,
} from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/constants';
@@ -19,6 +29,16 @@ import {
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
// options to any new block (event / delay / block)
after: string;
before: string;
// options to blocks of type OntimeEvent
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
}>;
/**
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
*/
@@ -47,31 +67,19 @@ export const useEventAction = () => {
networkMode: 'always',
});
// options to any new block (event / delay / block)
type BaseOptions = {
after?: string;
};
// options to blocks of type OntimeEvent
type EventOptions = BaseOptions &
Partial<{
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
}>;
/**
* Adds an event to rundown
*/
const addEvent = useCallback(
async (event: Partial<OntimeRundownEntry>, options?: EventOptions) => {
const newEvent: Partial<OntimeRundownEntry> = { ...event };
async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => {
const newEvent: TransientEventPayload = { ...event };
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) {
// merge creation time options with event settings
const applicationOptions = {
after: options?.after,
before: options?.before,
defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious,
@@ -121,11 +129,16 @@ export const useEventAction = () => {
// handle adding options that concern all event type
if (options?.after) {
// @ts-expect-error -- not sure how to type this, <after> is a transient property
newEvent.after = options.after;
}
if (options?.before) {
// @ts-expect-error -- not sure how to type this, <before> is a transient property
newEvent.before = options.before;
}
try {
await _addEventMutation.mutateAsync(newEvent);
await _addEventMutation.mutateAsync(newEvent as TransientEventPayload);
} catch (error) {
logAxiosError('Failed adding event', error);
}
@@ -203,13 +216,75 @@ export const useEventAction = () => {
[updateEvent],
);
type TimeField = 'timeStart' | 'timeEnd' | 'duration';
/**
* Updates time of existing event
* @param eventId {string} - id of the event
* @param field {TimeField} - field to update
* @param value {string} - new value string to be parsed
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
*/
const updateTimer = useCallback(
async (eventId: string, field: TimeField, value: string) => {
const getPreviousEnd = (): number => {
async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => {
// an empty value with no lock has no domain validity
if (!lockOnUpdate && value === '') {
return;
}
const newEvent: Partial<OntimeEvent> = {
id: eventId,
};
// check if we should lock the field
if (lockOnUpdate) {
if (field === 'timeEnd') {
// an empty value indicates that we should unlock the field
newEvent.timeStrategy = value === '' ? TimeStrategy.LockDuration : TimeStrategy.LockEnd;
newEvent.timeEnd = value === '' ? undefined : calculateNewValue();
} else if (field === 'duration') {
// an empty value indicates that we should unlock the field
newEvent.timeStrategy = value === '' ? TimeStrategy.LockEnd : TimeStrategy.LockDuration;
newEvent.duration = value === '' ? undefined : calculateNewValue();
} else if (field === 'timeStart') {
// an empty values means we should link to the previous
newEvent.linkStart = value === '' ? 'true' : null;
newEvent.timeStart = value === '' ? undefined : calculateNewValue();
}
} else {
newEvent[field] = calculateNewValue();
}
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
/**
* Utility function to calculate the new time value
*/
function calculateNewValue(): number {
let newValMillis = 0;
// check for previous keyword
if (value === 'p' || value === 'prev' || value === 'previous') {
newValMillis = getPreviousEnd();
// check for adding time keyword
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
} else {
newValMillis = parseUserTime(value);
}
// dont allow timer values over 23:59:59
return Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
}
/**
* Utility function to get the previous event end time
*/
function getPreviousEnd(): number {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) {
@@ -229,34 +304,6 @@ export const useEventAction = () => {
}
}
return previousEnd;
};
let newValMillis = 0;
// check for previous keyword
if (value === 'p' || value === 'prev' || value === 'previous') {
newValMillis = getPreviousEnd();
// check for adding time keyword
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
} else {
newValMillis = parseUserTime(value);
}
// dont allow timer values over 23:59:59
const cappedMillis = Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
const newEvent = {
id: eventId,
[field]: cappedMillis,
};
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEventMutation, queryClient],
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { debounce } from '../../common/utils/debounce';
import { throttle } from '../../common/utils/throttle';
export const useFadeOutOnInactivity = () => {
const [isMouseMoved, setIsMouseMoved] = useState(false);
@@ -16,11 +16,11 @@ export const useFadeOutOnInactivity = () => {
fadeOut = setTimeout(() => setIsMouseMoved(false), 3000);
};
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
document.addEventListener('mousemove', debouncedShowMenu);
document.addEventListener('mousemove', throttledShowMenu);
return () => {
document.removeEventListener('mousemove', debouncedShowMenu);
document.removeEventListener('mousemove', throttledShowMenu);
if (fadeOut) {
clearTimeout(fadeOut);
}
+18 -6
View File
@@ -65,6 +65,7 @@ export const useMessagePreview = () => {
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);
@@ -148,19 +149,21 @@ export const setAuxTimer = {
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
};
export const useCuesheet = () => {
export const useSelectedEventId = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
currentBlockId: state.currentBlock.block?.id ?? null,
selectedEventId: state.eventNow?.id ?? null,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
titleNow: state.eventNow?.title || '',
});
return useRuntimeStore(featureSelector);
};
export const useCurrentBlockId = () => {
const featureSelector = (state: RuntimeStore) => ({
currentBlockId: state.currentBlock.block?.id ?? null,
});
return useRuntimeStore(featureSelector);
};
export const setEventPlayback = {
loadEvent: (id: string) => socketSendJson('load', { id }),
startEvent: (id: string) => socketSendJson('start', { id }),
@@ -238,3 +241,12 @@ export const usePing = () => {
return useRuntimeStore(featureSelector);
};
/** convert ping into a derived value which changes less often */
export const useIsOnline = () => {
const featureSelector = (state: RuntimeStore) => ({
isOnline: state.ping > 0,
});
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: [],
};
@@ -15,4 +15,5 @@ export type ViewExtendedTimer = {
clock: number;
timerType: TimerType;
countToEnd: boolean;
};
+21 -3
View File
@@ -14,13 +14,31 @@ 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
* @param key
* @param value
*/
export function patchRuntime<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]): void {
export function patchRuntimeProperty<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
const state = runtimeStore.getState();
state[key] = value;
runtimeStore.setState({ ...state });
}
/**
* Allows patching the entire runtime store
*/
export function patchRuntime(patch: Partial<RuntimeStore>) {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...patch });
}
@@ -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();
})
});
+2 -2
View File
@@ -3,8 +3,8 @@ import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-uti
/**
* Parses a value in millis to a string which encodes a delay
* @param millis
* @param format
* @param millis
* @param format
*/
export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string {
if (millis == null || millis === 0) {
+4 -4
View File
@@ -1,12 +1,12 @@
export function debounce(callback: () => void, wait: number) {
export function debounce<T extends any[]>(callback: (...args: T) => void, wait: number) {
let timeout: NodeJS.Timeout | null;
return () => {
return (...args: T) => {
if (timeout) {
return;
clearTimeout(timeout);
}
timeout = setTimeout(() => {
timeout = null;
callback();
callback(...args);
}, wait);
};
}
@@ -7,7 +7,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
* @return {OntimeEvent} clean event
*/
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return {
type: SupportedEvent.Event,
title: event.title,
@@ -17,13 +17,16 @@ export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
timeEnd: event.timeEnd,
timerType: event.timerType,
timeStrategy: event.timeStrategy,
countToEnd: event.countToEnd,
linkStart: event.linkStart,
endAction: event.endAction,
isPublic: event.isPublic,
skip: event.skip,
colour: event.colour,
after,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
custom: {},
+8
View File
@@ -7,3 +7,11 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape';
}
export function preventEscape<T>(event: KeyboardEvent<T>, callback?: () => void): void {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
callback?.();
}
}
-30
View File
@@ -1,30 +0,0 @@
/**
* Returns hostname
* @type {string}
*/
export const host = window?.location?.host;
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* @param event
* @param location
*/
export function handleLinks(event, location) {
// we handle the link manually
event.preventDefault();
openLink(`http://${host}/${location}`);
}
+49
View File
@@ -0,0 +1,49 @@
import type { MouseEvent } from 'react';
import { baseURI, serverURL } from '../../externals';
/**
* Open an external URLs: specifically for a electron / browser case
* If electron: ask main process to call a new browser window
* If browser: open in new tab
* @param url
*/
export function openLink(url: string) {
if (window.process?.type === 'renderer') {
window.ipcRenderer.send('send-to-link', url);
} else {
window.open(url);
}
}
/**
* Handles opening external links
* serverUrl and baseURI are used for testing
*/
export function handleLinks(
event: MouseEvent,
location: string,
externalServerUrl: string = serverURL,
externalBaseURI: string = baseURI,
) {
// we handle the link manually
event.preventDefault();
const destination = new URL(externalServerUrl);
destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
openLink(destination.toString());
}
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 = 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
+40 -22
View File
@@ -14,7 +14,7 @@ import {
} from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger';
import { patchRuntime, runtimeStore } from '../stores/runtime';
import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime';
export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -39,12 +39,13 @@ export const connectSocket = () => {
}
socketSendJson('set-client-type', 'ontime');
socketSendJson('set-client-path', location.pathname + location.search);
setOnlineStatus(true);
};
websocket.onclose = () => {
console.warn('WebSocket disconnected');
setOnlineStatus(false);
if (shouldReconnect) {
reconnectTimeout = setTimeout(() => {
console.warn('WebSocket: attempting reconnect');
@@ -73,8 +74,8 @@ export const connectSocket = () => {
switch (type) {
case 'pong': {
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
patchRuntime('ping', offset);
updateDevTools({ ping: offset }, ['PING']);
patchRuntimeProperty('ping', offset);
updateDevTools({ ping: offset });
break;
}
case 'client-id': {
@@ -131,64 +132,65 @@ export const connectSocket = () => {
break;
}
case 'ontime': {
runtimeStore.setState(payload as RuntimeStore);
if (!isProduction) {
ontimeQueryClient.setQueryData(RUNTIME, data.payload);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload
const { ping, ...serverPayload } = payload as Partial<RuntimeStore>;
patchRuntime(serverPayload);
updateDevTools(serverPayload);
break;
}
case 'ontime-clock': {
patchRuntime('clock', payload);
addToBatchUpdates('clock', payload);
updateDevTools({ clock: payload });
break;
}
case 'ontime-timer': {
patchRuntime('timer', payload);
addToBatchUpdates('timer', payload);
updateDevTools({ timer: payload });
break;
}
case 'ontime-onAir': {
patchRuntime('onAir', payload);
addToBatchUpdates('onAir', payload);
updateDevTools({ onAir: payload });
break;
}
case 'ontime-message': {
patchRuntime('message', payload);
addToBatchUpdates('message', payload);
updateDevTools({ message: payload });
break;
}
case 'ontime-runtime': {
patchRuntime('runtime', payload);
addToBatchUpdates('runtime', payload);
updateDevTools({ runtime: payload });
break;
}
case 'ontime-eventNow': {
patchRuntime('eventNow', payload);
addToBatchUpdates('eventNow', payload);
updateDevTools({ eventNow: payload });
break;
}
case 'ontime-currentBlock': {
patchRuntime('currentBlock', payload);
addToBatchUpdates('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': {
patchRuntime('publicEventNow', payload);
addToBatchUpdates('publicEventNow', payload);
updateDevTools({ publicEventNow: payload });
break;
}
case 'ontime-eventNext': {
patchRuntime('eventNext', payload);
addToBatchUpdates('eventNext', payload);
updateDevTools({ eventNext: payload });
break;
}
case 'ontime-publicEventNext': {
patchRuntime('publicEventNext', payload);
addToBatchUpdates('publicEventNext', payload);
updateDevTools({ publicEventNext: payload });
break;
}
case 'ontime-auxtimer1': {
patchRuntime('auxtimer1', payload);
addToBatchUpdates('auxtimer1', payload);
updateDevTools({ auxtimer1: payload });
break;
}
@@ -205,6 +207,10 @@ export const connectSocket = () => {
}
break;
}
case 'ontime-flush': {
flushBatchUpdates()
break;
}
}
} catch (_) {
// ignore unhandled
@@ -232,11 +238,23 @@ export const socketSendJson = (type: string, payload?: unknown) => {
);
};
function updateDevTools(newData: Partial<RuntimeStore>, store = RUNTIME) {
function updateDevTools(newData: Partial<RuntimeStore>) {
if (!isProduction) {
ontimeQueryClient.setQueryData(store, (oldData: RuntimeStore) => ({
ontimeQueryClient.setQueryData(RUNTIME, (oldData: RuntimeStore) => ({
...oldData,
...newData,
}));
}
}
/**
* Allows setting the status of the client
* We leverage the ping as an indication of the client's online status
* @example ping < 0 - client is offline
* @example ping > 0 -> client is online
*/
function setOnlineStatus(status: boolean) {
const derivedPing = status ? 1 : -1;
patchRuntimeProperty('ping', derivedPing);
updateDevTools({ ping: derivedPing });
}
+12
View File
@@ -0,0 +1,12 @@
export function throttle<T extends any[]>(callback: (...args: T) => void, wait: number) {
let timeout: NodeJS.Timeout | null;
return (...args: T) => {
if (timeout) {
return;
}
timeout = setTimeout(() => {
timeout = null;
callback(...args);
}, wait);
};
}
+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();
}
+21
View File
@@ -6,6 +6,9 @@ declare module '*.scss' {
type ListenerType = (event: 'string', args: unknown[]) => void;
declare global {
/**
* Register the electron properties
*/
interface Window {
ipcRenderer: {
send: (channel: string, args?: string | object) => void;
@@ -17,6 +20,24 @@ declare global {
}
}
/**
* We pass a custom property to the table meta to allow field update
*/
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData extends RowData> {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
};
}
}
/**
* Allow passing CSS Properties
*/
declare module 'react' {
interface CSSProperties {
[key: `--${string}`]: string | number;
+59 -9
View File
@@ -1,8 +1,9 @@
import { version } from '../../../package.json';
/**
* This file contains a list of constants that may need to be resolved at runtime
*/
import { version } from '../../../package.json';
export const githubUrl = 'https://www.github.com/cpvalente/ontime';
export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/releases/latest';
export const websiteUrl = 'https://www.getontime.no';
@@ -11,18 +12,67 @@ 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';
export const isDev = !isProduction;
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
export const isOntimeCloud = Boolean(import.meta.env.VITE_IS_CLOUD);
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
// resolve protocol
const socketProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
// resolve entrypoint URLs
// resolve port
const STATIC_PORT = 4001;
export const serverPort = isProduction ? window.location.port : STATIC_PORT;
export const serverURL = `${window.location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
/**
* Resolve base
* @example '' for electron
* @example '/client-hash' for cloud
*/
export const baseURI = resolveBaseURI();
export const serverURL = resolveUrl('http', '');
export const websocketUrl = resolveUrl('ws', 'ws');
function resolveUrl(protocol: 'http' | 'ws', path: string) {
const url = new URL(window.location.origin);
// generate ws url
if (protocol === 'ws') {
// ensure we remain in a secure context
const isSecure = window.location.protocol === 'https:';
url.protocol = isSecure ? 'wss' : 'ws';
}
// make path name relative to the base URI
url.pathname = baseURI ? `${baseURI}/${path}` : path;
// in development mode, we use the React port for UI, but need the requests to target the server
if (isDev) {
// this is used as a fallback port for development
url.port = '4001';
}
const result = url.toString();
// prevent trailing slash
return result.endsWith('/') ? result.slice(0, -1) : result;
}
/**
* Resolves a base URI for a client that is not at the root segment
* ie: https://cloud.getontime.com/client-hash/timer
* This is necessary for ontime cloud and should otherwise not affect the client
*/
function resolveBaseURI(): string {
// in ontime cloud, the base tag is set by the server
const baseHref = document.querySelector('base')?.getAttribute('href');
const base = baseHref ?? '';
// prevent a trailing slash from either an empty base or a base with a trailing slash
if (base.endsWith('/')) {
return base.slice(0, -1);
}
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;
}
@@ -70,7 +70,7 @@ function PanelListItem(props: PanelListItemProps) {
</li>
{panel.secondary?.map((secondary) => {
const id = secondary.id.split('__')[1];
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
const secondaryClasses = cx([style.secondary, isSelected && location === id ? style.active : null]);
return (
<li
key={secondary.id}
@@ -26,17 +26,26 @@ $inner-padding: 1rem;
color: $gray-300;
}
.section {
position: relative;
margin-top: 2rem;
.section,
.indent {
font-size: calc(1rem - 1px);
max-width: 800px;
display: flex;
flex-direction: column;
gap: 1rem;
color: $ui-white;
}
.section {
position: relative;
margin-top: 2rem;
max-width: 850px;
}
.indent {
padding: 1rem;
background-color: $gray-1350;
}
.paragraph {
padding: 0.5rem 0;
}
@@ -84,7 +93,6 @@ $inner-padding: 1rem;
position: sticky;
top: 0;
z-index: 3;
box-shadow: 0 1px $white-10;
background-color: $gray-1350;
}
@@ -103,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 {
@@ -163,6 +177,38 @@ $inner-padding: 1rem;
animation: animloader 1s ease-in infinite;
}
.empty {
background-color: $black-10;
text-align: center;
color: $muted-gray;
td {
padding-block: 1rem;
}
button {
margin-top: 1rem;
}
}
.inlineElements {
display: flex;
align-items: center;
&.inner {
gap: 0.5rem;
}
&.component {
gap: 1rem;
}
&.start {
justify-content: flex-start;
}
&.end {
justify-content: flex-end;
}
}
@keyframes animloader {
0% {
transform: scale(0);
@@ -1,4 +1,6 @@
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';
import { cx } from '../../../common/utils/styleUtils';
@@ -22,10 +24,19 @@ type SectionProps<C extends AllowedTags> = {
children: ReactNode;
} & JSX.IntrinsicElements[C];
export function Section<C extends AllowedTags = 'div'>({ as, children, ...props }: SectionProps<C>) {
export function Section<C extends AllowedTags = 'div'>({ as, className, children, ...props }: SectionProps<C>) {
const Element = as ?? 'div';
return (
<Element className={style.section} {...(props as HTMLAttributes<HTMLElement>)}>
<Element className={cx([style.section, className])} {...(props as HTMLAttributes<HTMLElement>)}>
{children}
</Element>
);
}
export function Indent<C extends AllowedTags = 'div'>({ as, className, children, ...props }: SectionProps<C>) {
const Element = as ?? 'div';
return (
<Element className={cx([style.indent, className])} {...(props as HTMLAttributes<HTMLElement>)}>
{children}
</Element>
);
@@ -44,16 +55,28 @@ 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 ListGroup({ children }: { children: ReactNode }) {
return <ul className={style.listGroup}>{children}</ul>;
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
return (
<tr className={style.empty}>
<td colSpan={99}>
<div>{label ?? 'No data yet'}</div>
<Button onClick={handleClick} isDisabled={!handleClick} variant='ontime-filled' rightIcon={<IoAdd />} size='sm'>
New
</Button>
</td>
</tr>
);
}
export function ListGroup({ className, children }: { className?: string; children: ReactNode }) {
return <ul className={cx([style.listGroup, className])}>{children}</ul>;
}
export function ListItem({ children }: { children: ReactNode }) {
@@ -100,3 +123,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,74 @@
.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 {
display: grid;
grid-gap: 0.5rem;
button {
align-self: flex-end;
}
}
.titleSection,
.ruleSection,
.filterSection,
.oscSection,
.httpSection {
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;
}
.outputCard {
border-left: 0.25rem solid $gray-1200;
padding-left: 0.5rem;
}
@@ -0,0 +1,479 @@
import { useEffect, useMemo } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import {
Alert,
AlertDescription,
AlertIcon,
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, isOSCOutput, 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 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 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,
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 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 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</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) => (
<div key={field.id} 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 }) => (
<option key={value} 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>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Automation outputs can be used to send data from Ontime to external software.
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
</AlertDescription>
</Alert>
{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>
Parameters
<TemplateInput
{...register(`outputs.${index}.args`)}
variant='ontime-filled'
size='sm'
placeholder='1'
autoComplete='off'
/>
<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
isDisabled={false}
isLoading={false}
/>
</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
isDisabled={false}
isLoading={false}
/>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
// there should be no other output types
return null;
})}
<Panel.InlineElements relation='inner'>
<Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
onClick={handleAddNewOSCOutput}
isDisabled={false}
isLoading={false}
>
OSC
</Button>
<Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
onClick={handleAddNewHTTPOutput}
isDisabled={false}
isLoading={false}
>
HTTP
</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,161 @@
import { Controller, useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, 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 { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
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>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Control Ontime and share its data with external systems in your workflow. <br />
- Automations allow Ontime to send its data on lifecycle triggers. <br />- OSC Input tells Ontime to listen
to messages on the specific port. <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</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>
<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,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 before 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]);
});
});

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