Compare commits

..

73 Commits

Author SHA1 Message Date
Carlos Valente 71092afc02 ontime release (#694)
* chore: version bump

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2024-01-10 20:50:19 +01:00
Alex Christoffer Rasmussen b1ba47a80f Fix/external path (#702)
* leave styles as is but move other external to `external` folder
2024-01-09 22:57:06 +01:00
Alex Christoffer Rasmussen 9611722a6d fix: use background-color: transparent; for overlap indicators (#699) 2024-01-09 16:42:50 +01:00
Alex Christoffer Rasmussen 92cefa1331 refactor: errors from integrationcontroller (#700)
* refactor: these functions don't throw, test the return success and then throw

* refactor: no need to catch and re-throw
2024-01-09 16:01:17 +01:00
Alex Christoffer Rasmussen c0fb865959 feat: google sheets(#579)
---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-01-08 15:51:58 +01:00
Oliver Herrmann 068ec800f4 add localdocker build commands (#638) 2024-01-05 21:04:52 +01:00
Alex Christoffer Rasmussen 1a8b35a5ea feat: show overlap (#633)
* feat: show spacing or overlap
2024-01-05 21:04:16 +01:00
Fabian Posenau 40bede200c nightly builds hopefully (#681)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-30 10:04:42 +01:00
Fabian Posenau f3e2944c55 fix docker compose ontime version (#680)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-29 22:25:41 +01:00
Fabian Posenau 09f2874784 Fix Visual bug on rundown (#678)
* fix: bug with draw order on past rundown items

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-29 20:18:05 +01:00
Carlos Valente 8afffb9870 style: fix missing overrides (#666)
* style: fix missing overrides

* style: fix recover idle colour
2023-12-22 21:05:23 +01:00
Carlos Valente 33575c551a refactor: improve typing on transform (#626)
* refactor: improve typing on transform

* chore: use placeholder for data errors
2023-12-19 10:03:25 +01:00
Carlos Valente 3173fb57fc chore: add italian to languages (#656) 2023-12-18 15:07:48 +01:00
Carlos Valente 7d33019f53 refactor: prevent accidental edit in operator view (#659)
* refactor: prevent accidental edit in operator view
2023-12-18 15:07:30 +01:00
Matteo Gheza 6526b32d4c Add italian client translation (#655)
* Add Italian client translation
2023-12-17 20:59:05 +01:00
Carlos Valente d6aef5ea31 fix: correct link for CSS overrides (#650) 2023-12-14 20:14:09 +01:00
Alex Christoffer Rasmussen 8756b396cc feat: minimal demo page (#644)
* minimal

* eslint-browser

* dynamic port, reconnect socket, msTo string without Date

* 404 on external not found
2023-12-14 13:27:18 +01:00
Carlos Valente 5a2f711eab refactor: cleanup sentry errors (#653) 2023-12-13 22:12:54 +01:00
Carlos Valente a56e2b1c1c fix: link for mac download (#648) 2023-12-13 19:22:17 +01:00
Carlos Valente da829dc09f Release preparations (#642)
* chore: version bump
2023-12-11 22:06:19 +01:00
Carlos Valente c4d65dbe5f fix: display correct end time in backstage (#635) 2023-12-10 19:12:29 +01:00
Alex Christoffer Rasmussen 2586b0b10c feat: http integration (#575)
* addtime endpoint

* create universal Subscription

* add http integration

* catch error from HTTP integration emit

* unify osc and http validateSubscriptionEntry

* add necessary endpoint for http subscription

* make subscription part of modal generic

* add http subscription to integration modal

* remove log

* Revert "addtime endpoint"

This reverts commit 4c039220dc.

* reuse agent and test url compatibility

* simplify retun path

* add todo in UI

* test for http protocol

* import not needed yet

* lint

* fix merge

* lint

* fix httpPlaceholder

* wip: prepare endpoints

* wip: temporary fix to get form to work

* disable HTTP integration if enabledOut==false

* register/unregister http

* refactor: subscription types and form register

* refactor: validation

* cleanup

* cleanup

* try GOT

* allow https

* split url and searchParams allow for post option

* add options to post

* add retry count

* fix test

* Revert "fix test"

This reverts commit 927e88370f.

* Revert "add options to post"

This reverts commit 0523a68ef4.

* Revert "split url and searchParams allow for post option"

This reverts commit 54ab8d4ffe.

* missing retryCount in httpPlaceholder

* remove global this

* remove retry count

* remove https

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2023-12-10 16:56:42 +01:00
Alex Christoffer Rasmussen abe27d54a2 refactor: use add time in client (#636) 2023-12-08 21:46:29 +01:00
Alex Christoffer Rasmussen 1343818917 Improve change api (#628)
* fix: jsdoc comments

* more human casting of string values to boolean

* add coerceColour to get named colors to work

* cue can't pass isKeyOfType event check

* dont mutate value

* alloow cue in eventDef

* lint

* css named or hex formatting

* "sideEffects": false

* test colour hex regex

* roll eventDef back to master

* parse property

* don't export cssColour names

* remove import

* only allow string types to colour

* handle transparent colour

* mix alpha with bg colour

* only allow updating events

* descriptive names

* add test for getAccessibleColour

* readability

* move isColourHex to regex-utils

* simplify and add test
2023-12-08 12:55:04 +01:00
Carlos Valente a93cd3e9b1 style: prevent menu mirroring (#632) 2023-12-07 17:57:04 +01:00
Alex Christoffer Rasmussen 813eb9ad86 Merge pull request #604 from cpvalente/roll-back
add skippedOutOfEvent to TimerService
2023-12-04 12:53:43 +01:00
Carlos Valente 34e9b1ef07 refactor: clarify responsabilities 2023-11-30 21:35:09 +01:00
arc-alex a67b89190d guard and test midnight 2023-11-28 16:04:12 +01:00
arc-alex ecd151a0a8 use expectedFinish 2023-11-28 15:27:19 +01:00
asharonbaltazar 4cbc09864c Params Input Placeholders (#627)
* add `placeholder?: string;` to `StringField` & `NumberField`

* add `placeholder` to inputs

* move values from `defaultValue` to `placeholder`
2023-11-27 18:25:46 -05:00
arc-alex 20d5d8b129 move skip logic to timerUtils 2023-11-27 17:42:42 +01:00
Alex Christoffer Rasmussen 11db39ee73 Merge pull request #623 from cpvalente/osc-enabledOut
Osc disable/enable output
2023-11-27 16:56:54 +01:00
asharonbaltazar edb79d5819 Default Values in ParamEditor (#610)
* add `defaultvalue` to `Field` types

* add `defaultValue` to constants; turn into functions for time values

* update constant imports/calls in view components

* use `defaultValue`s in `ParamInput`

* change `Clear` to `Reset`to better reflect actions

* make `defaultValue` optional rather than `undefined`

* update forgotten constants

* add `defaultValue` to boolean input

* change `101010` to `000000`

* add `prefix` property to types

* implement `prefix` for `ParamInput`s

* change `paramField` to `prop`

* add `onEditDrawerClose` to `resetParams`

* `ViewParamsEditor` omits default values

* undo close on reset

* move `useSettings` into `ViewWrapper`

* change `settings` to include `undefined`
2023-11-26 15:57:18 -05:00
arc-alex 715fa66444 disable/enable from ui without restart 2023-11-26 20:41:33 +01:00
arc-alex 27f6d10677 actually disable/enable osc output 2023-11-26 20:41:01 +01:00
Carlos Valente f2504072ce feat: add change endpoint to ws (#618)
* feat: add change endpoint to ws
2023-11-26 19:02:16 +01:00
Carlos Valente 9e285ee8f3 chore: version bump (#616) 2023-11-25 22:31:22 +01:00
Carlos Valente 1ccf2108eb style: tweaks on info data (#622) 2023-11-25 22:31:09 +01:00
Carlos Valente db56ac8049 style: separe arguments in copy fields (#621) 2023-11-25 22:28:54 +01:00
Carlos Valente 0660f014c8 fix: prevent autocomplete (#620) 2023-11-25 22:04:47 +01:00
Carlos Valente 41c26778cb fix: prevent shortcut collision with menu (#619) 2023-11-25 21:55:27 +01:00
Carlos Valente 804e4ad33e chore: disable save params feature (#615) 2023-11-24 18:29:37 +01:00
arc-alex b11041938d self documenting 2023-11-24 14:05:12 +01:00
Carlos Valente 5be60ff6c3 feat: show progress in op and cuesheet (#605) 2023-11-24 12:04:38 +01:00
Carlos Valente bf8ebe942e feat: allow editing field from operator (#609)
* feat: allow editing field from operator

Co-authored-by: arc-alex <ac@omnivox.dk>

* style: functional and presentation tweaks
---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2023-11-24 12:04:22 +01:00
Carlos Valente bdd216d881 feat: customise timer elements (#607)
* feat: customise timer elements
2023-11-22 21:38:35 +01:00
arc-alex e6b4f537af rollSkipLimit 2023-11-22 19:05:08 +01:00
Alex Christoffer Rasmussen 797a0edf57 External field (#580)
* feat: add external endpoint

* add external to messagecontrol

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2023-11-22 08:06:48 +01:00
asharonbaltazar c4886a617c remove storedViewParams from the dep array (#606) 2023-11-20 18:41:41 -05:00
Carlos Valente a79ae7e0c4 fix: cuesheet header (#600)
* `useLocalStorage` hook uses `useSyncExternalStore`
---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
2023-11-20 21:11:52 +01:00
arc-alex 8cabb347e9 remove test log 2023-11-20 18:24:11 +01:00
arc-alex 22d89c6fbc add _startAt to all tests 2023-11-20 17:51:50 +01:00
arc-alex facce4f096 trigger doRoll if clock is less than startAt 2023-11-20 17:44:39 +01:00
Carlos Valente afa62a6e38 test: alias feature (#602) 2023-11-20 11:45:17 +01:00
Carlos Valente 1b55dbc170 style: clock period (#601)
* style: tweaks to studio override

* style: improvements to 12 hour format
2023-11-20 09:53:35 +01:00
Carlos Valente 2316bbebac chore: type improvements (#598)
* chore: type improvements

* ci: typechecking in pipeline
2023-11-18 08:35:54 +01:00
Carlos Valente 884ab0b67b refactor: rundown (#597)
* refactor: add revision number to rundown

* chore: migrate query
2023-11-17 16:18:02 +01:00
Carlos Valente 58239af8bb fix: distinguish user initiated scroll (#596) 2023-11-17 13:01:28 +01:00
Alex Christoffer Rasmussen c59e070076 Local build (#595)
* add build:electron script

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2023-11-17 13:01:08 +01:00
Carlos Valente fad8d2a933 hotfix ci (#594)
* ci: upgrade pnpm

* version bump
2023-11-16 11:26:01 +01:00
Carlos Valente 54def8b820 feat: small improvements to instance info (#591)
* refactor: add typings to backend info

* refactor: write current version to project file

* feat: show location of css override
2023-11-15 20:20:05 +01:00
Carlos Valente 536e447eaa style: export modal (#592)
* style: tweaks to export modal
2023-11-14 22:31:14 +01:00
Carlos Valente 1ca3b9c134 refactor: better errors (#590)
* refactor: improve error messages from server
2023-11-14 21:54:56 +01:00
Carlos Valente a41fe8806b configure lint staged (#588)
* chore: upgrade monorepo deps

* chore: configure lint-staged

* refactor: lenient use of any
2023-11-13 22:04:55 +01:00
Alex Christoffer Rasmussen 140daef7e7 add eventsPerPage to schedule (#583)
* add eventsPerPage to schedule

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2023-11-13 22:03:17 +01:00
Carlos Valente cbed3e4fe4 ci: remove stale bot (#587) 2023-11-12 21:41:55 +01:00
Carlos Valente 9da7613450 ci: close issues without user activity (#586)
* ci: close issues without user activity
2023-11-11 14:58:46 +01:00
Carlos Valente a7980ef2ab fix: github workflow (#585)
* fix: github workflow

* chore: update lockfile
2023-11-11 14:42:06 +01:00
Carlos Valente 45fe669f0a feat: excel import (#527)
* refactor: simplify excel import

* chore: add external deepmerge utility

* chore: create import map utilities

* chore: increase size limits on uploads

* style: small presentation tweaks

* feat: resolve times on excel import, refs #508
2023-11-11 14:28:43 +01:00
asharonbaltazar 0ce3449083 View Params Persist when Navigating Back (#581)
* add url search params to local storage

* add clearing local storage

* use `useLocation` instead of `location.pathname`
2023-11-10 08:49:25 -05:00
Carlos Valente de715b3d3e chore: upgrade pnpm (#582)
* chore: upgrade pnpm
2023-11-09 21:34:17 +01:00
Carlos Valente 6012cb7bd1 chore(lint): lenient eol (#578)
* chore(lint): lenient eol
2023-11-07 22:39:47 +01:00
Carlos Valente 2c47d90f34 chore: configure project wide linter (#577)
* chore(lint): configure project-wide linter

* chore(lint): improve linting in files

* chore(lint): hide warnings in CI
2023-11-07 22:30:39 +01:00
268 changed files with 10482 additions and 4148 deletions
+1
View File
@@ -0,0 +1 @@
"ONTIME_VERSION.js"
+38 -2
View File
@@ -1,4 +1,5 @@
{
"root": true,
"parserOptions": {
"ecmaVersion": 2020
},
@@ -6,8 +7,16 @@
"es6": true,
"jest": true
},
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended"
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
"eslint-config-prettier"
],
"plugins": [
"@typescript-eslint",
"prettier"
],
"overrides": [
{
@@ -21,6 +30,33 @@
}
],
"rules": {
"no-console": ["warn", { "allow": ["warn", "error"]}]
"no-useless-concat": "warn",
"prefer-template": "warn",
"no-console": [
"warn",
{
"allow": [
"warn",
"error"
]
}
],
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"prettier/prettier": [
"warn",
{
"endOfLine": "auto"
}
]
}
}
+19 -7
View File
@@ -16,17 +16,17 @@ jobs:
- uses: actions/checkout@v3
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Setup Node.js environment
uses: actions/setup-node@v3.6.0
with:
version: 16.16.0
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
uses: pnpm/action-setup@v2
with:
version: 7.26.3
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -34,7 +34,7 @@ jobs:
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm turbo build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
@@ -44,7 +44,8 @@ jobs:
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Build and push Docker images
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
with:
context: .
@@ -54,3 +55,14 @@ jobs:
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+6 -6
View File
@@ -17,9 +17,9 @@ jobs:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
uses: pnpm/action-setup@v2
with:
version: 7.26.3
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -52,9 +52,9 @@ jobs:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
uses: pnpm/action-setup@v2
with:
version: 7.26.3
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -85,9 +85,9 @@ jobs:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
uses: pnpm/action-setup@v2
with:
version: 7.26.3
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
+28 -4
View File
@@ -20,23 +20,47 @@ jobs:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
uses: pnpm/action-setup@v2
with:
version: 7.26.3
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Run code quality per package
- name: React - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Server - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/server
- name: Utils - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./packages/utils
- name: Types - Run linter
if: always()
run: pnpm lint
working-directory: ./packages/types
# We choose to run tests separately
- name: React - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/client
- name: Server - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/server
- name: Utils - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./packages/utils
@@ -52,9 +76,9 @@ jobs:
node-version: 16
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
uses: pnpm/action-setup@v2
with:
version: 7.26.3
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
+2 -1
View File
@@ -76,7 +76,7 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo build:local`
- __Build the UI and server__ by running `turbo build:electron`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
The build distribution assets will be at `.apps/electron/dist`
@@ -89,6 +89,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
- __Install the project dependencies__ by running `pnpm i`
- __Build packages__ by running `pnpm build:localdocker`
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d`
+1 -1
View File
@@ -4,7 +4,7 @@
## Download the latest releases here
<div style="display: flex; justify-content: space-around">
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
+2 -18
View File
@@ -8,34 +8,18 @@
"browser": true,
"node": true
},
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"eslint-config-prettier",
"plugin:@tanstack/eslint-plugin-query/recommended",
"prettier"
"plugin:@tanstack/eslint-plugin-query/recommended"
],
"plugins": [
"react",
"testing-library",
"simple-import-sort",
"@tanstack/query",
"@typescript-eslint",
"prettier"
"@tanstack/query"
],
"rules": {
"@typescript-eslint/no-non-null-assertion": "warn",
"prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
],
"no-useless-concat": "warn",
"prefer-template": "warn",
"react/jsx-no-bind": [
"error",
{
+16 -14
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "2.13.1",
"version": "2.28.11",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.7.0",
@@ -12,8 +12,8 @@
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.46.0",
"@sentry/tracing": "^7.46.0",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-query-devtools": "^4.29.0",
"@tanstack/react-query": "^5.8.4",
"@tanstack/react-query-devtools": "^5.8.4",
"@tanstack/react-table": "^8.9.2",
"autosize": "^6.0.1",
"axios": "^1.2.0",
@@ -38,8 +38,11 @@
"dev": "cross-env BROWSER=none vite",
"build": "vite build",
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "vite build",
"lint": "eslint .",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build"
@@ -58,22 +61,21 @@
},
"devDependencies": {
"@sentry/vite-plugin": "^0.4.0",
"@tanstack/eslint-plugin-query": "^4.26.2",
"@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/luxon": "^3.2.0",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.1",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^3.0.1",
"eslint": "^8.31.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-jest": "^27.1.7",
"eslint-plugin-prettier": "^4.2.1",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.6.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
@@ -81,9 +83,9 @@
"jsdom": "^21.1.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "^2.8.3",
"prettier": "^3.0.3",
"sass": "^1.57.1",
"typescript": "^4.9.4",
"typescript": "^5.2.2",
"vite": "^4.3.1",
"vite-plugin-compression2": "^0.9.0",
"vite-plugin-svgr": "^2.4.0",
+3 -2
View File
@@ -2,13 +2,14 @@
export const PROJECT_DATA = ['project'];
export const ALIASES = ['aliases'];
export const USERFIELDS = ['userFields'];
export const RUNDOWN_TABLE_KEY = 'rundown';
export const RUNDOWN_TABLE = [RUNDOWN_TABLE_KEY];
export const RUNDOWN = ['rundown'];
export const APP_INFO = ['appinfo'];
export const OSC_SETTINGS = ['oscSettings'];
export const HTTP_SETTINGS = ['httpSettings'];
export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings'];
export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
+33 -5
View File
@@ -2,18 +2,32 @@ import axios, { AxiosError } from 'axios';
import { LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
export function logAxiosError(prepend: string, error: unknown) {
let message;
export function maybeAxiosError(error: unknown) {
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
const data = (error as AxiosError).response?.data ?? '';
message = `${prepend} ${statusText}: ${data}`;
let data = (error as AxiosError).response?.data ?? '';
if (typeof data === 'object') {
if ('message' in data) {
data = JSON.stringify(data.message);
} else {
data = JSON.stringify(data);
}
}
return `${statusText}: ${data}`;
} else {
message = `${prepend}: ${error}`;
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
}
}
export function logAxiosError(prepend: string, error: unknown) {
const message = `${prepend}: ${maybeAxiosError(error)}`;
addLog({
id: generateId(),
@@ -23,3 +37,17 @@ export function logAxiosError(prepend: string, error: unknown) {
text: message,
});
}
/**
* Utility function invalidates react-query caches
*/
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['aliases'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['userFields'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['rundown'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appinfo'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['oscSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['appSettings'] });
await ontimeQueryClient.invalidateQueries({ queryKey: ['viewSettings'] });
}
+11 -1
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { GetRundownCached, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { rundownURL } from './apiConstants';
@@ -7,6 +7,16 @@ import { rundownURL } from './apiConstants';
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchCachedRundown(): Promise<GetRundownCached> {
const res = await axios.get(`${rundownURL}/cached`);
return res.data;
}
/**
* @deprecated use fetchCachedRundown instead
* @description HTTP request to fetch all events
* @return {Promise}
*/
export async function fetchRundown(): Promise<OntimeRundown> {
const res = await axios.get(rundownURL);
return res.data;
+160 -9
View File
@@ -1,8 +1,20 @@
import axios from 'axios';
import { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, ViewSettings } from 'ontime-types';
import axios, { AxiosResponse } from 'axios';
import {
Alias,
DatabaseModel,
GetInfo,
HttpSettings,
OntimeRundown,
OSCSettings,
OscSubscription,
ProjectData,
Settings,
UserFields,
ViewSettings,
} from 'ontime-types';
import { ExcelImportMap } from 'ontime-utils';
import { apiRepoLatest } from '../../externals';
import { InfoType } from '../models/Info';
import fileDownload from '../utils/fileDownload';
import { ontimeURL } from './apiConstants';
@@ -28,7 +40,7 @@ export async function postSettings(data: Settings) {
* @description HTTP request to retrieve application info
* @return {Promise}
*/
export async function getInfo(): Promise<InfoType> {
export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${ontimeURL}/info`);
return res.data;
}
@@ -93,6 +105,23 @@ export async function getOSC(): Promise<OSCSettings> {
return res.data;
}
/**
* @description HTTP request to retrieve http settings
* @return {Promise}
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(`${ontimeURL}/http`);
return res.data;
}
/**
* @description HTTP request to mutate http settings
* @return {Promise}
*/
export async function postHTTP(data: HttpSettings) {
return axios.post(`${ontimeURL}/http`, data);
}
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
@@ -123,17 +152,25 @@ export const downloadRundown = () => {
return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' });
};
// TODO: should this be extracted to shared code?
export type ProjectFileImportOptions = {
onlyRundown: boolean;
};
/**
* @description HTTP request to upload events db
* @return {Promise}
*/
type UploadDataOptions = {
onlyRundown?: boolean;
};
export const uploadData = async (file: File, setProgress: (value: number) => void, options?: UploadDataOptions) => {
export const uploadProjectFile = async (
file: File,
setProgress: (value: number) => void,
options?: Partial<ProjectFileImportOptions>,
) => {
const formData = new FormData();
formData.append('userFile', file);
const onlyRundown = options?.onlyRundown || 'false';
const onlyRundown = Boolean(options?.onlyRundown);
await axios
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
headers: {
@@ -147,6 +184,47 @@ export const uploadData = async (file: File, setProgress: (value: number) => voi
.then((response) => response.data.id);
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise}
*/
export async function patchData(patchDb: Partial<DatabaseModel>) {
const response = await axios.patch(`${ontimeURL}/db`, patchDb);
return response;
}
type PostPreviewExcelResponse = {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise} - returns parsed rundown and userfields
*/
export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: ExcelImportMap) {
const formData = new FormData();
formData.append('userFile', file);
formData.append('options', JSON.stringify(options));
const response: AxiosResponse<PostPreviewExcelResponse> = await axios.post(
`${ontimeURL}/preview-spreadsheet`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
setProgress(complete);
},
},
);
return response;
}
export type HasUpdate = {
url: string;
version: string;
@@ -167,3 +245,76 @@ export async function getLatestVersion(): Promise<HasUpdate> {
export async function postNew(initialData: Partial<ProjectData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
/**
* @description STEP 1
*/
export const uploadSheetClientFile = async (file: File) => {
const formData = new FormData();
formData.append('userFile', file);
const res = await axios
.post(`${ontimeURL}/sheet/clientsecret`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then((response) => response.data.id);
return res;
};
/**
* @description STEP 1 test
*/
export const getClientSecrect = async () => {
const response = await axios.get(`${ontimeURL}/sheet/clientsecret`);
return response.data;
};
/**
* @description STEP 2
*/
export const getSheetsAuthUrl = async () => {
const response = await axios.get(`${ontimeURL}/sheet/authentication/url`);
return response.data;
};
/**
* @description STEP 2 test
*/
export const getAuthentication = async () => {
const response = await axios.get(`${ontimeURL}/sheet/authentication`);
return response.data;
};
/**
* @description STEP 3
* @returns worksheetOptions
*/
export const postId = async (id: string) => {
const response = await axios.post(`${ontimeURL}/sheet/id`, { id });
return response.data;
};
/**
* @description STEP 4
*/
export const postWorksheet = async (id: string, worksheet: string) => {
const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { id, worksheet });
return response.data;
};
/**
* @description STEP 5
*/
export const postPreviewSheet = async (id: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet/pull`, { id, options });
return response.data.data;
};
/**
* @description STEP 5
*/
export const postPushSheet = async (id: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options });
return response.data.data;
};
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft';
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
import Swatch from './Swatch';
@@ -8,8 +8,8 @@ import style from './SwatchSelect.module.scss';
interface ColourInputProps {
value: string;
name: TitleActions;
handleChange: (newValue: TitleActions, name: string) => void;
name: EditorUpdateFields;
handleChange: (newValue: EditorUpdateFields, name: string) => void;
}
const colours = [
@@ -19,9 +19,11 @@ interface TextInputProps extends BaseProps {
isTextArea?: false;
}
type ResizeOptions = 'horizontal' | 'vertical' | 'none';
interface TextAreaProps extends BaseProps {
isTextArea: true;
resize?: 'horizontal' | 'vertical' | 'none';
resize?: ResizeOptions;
}
type InputProps = TextInputProps | TextAreaProps;
@@ -35,7 +37,7 @@ export default function TextInput(props: InputProps) {
const textInputProps = useReactiveTextInput(initialText, submitCallback, { submitOnEnter: true });
const textAreaProps = useReactiveTextInput(initialText, submitCallback);
let resize = 'none';
let resize: ResizeOptions = 'none';
if (isTextArea) {
resize = (props as TextAreaProps)?.resize ?? 'none';
}
@@ -17,9 +17,8 @@ interface TimeInputProps {
time?: number;
delay?: number;
placeholder: string;
validationHandler: (entry: TimeEntryField, val: number) => boolean;
previousEnd?: number;
warning?: string;
tooltip?: string;
}
function ButtonInitial(name: TimeEntryField) {
@@ -29,25 +28,15 @@ function ButtonInitial(name: TimeEntryField) {
return '';
}
function ButtonTooltip(name: TimeEntryField, warning?: string) {
if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`;
if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`;
if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`;
function ButtonTooltip(name: TimeEntryField, tooltip?: string) {
if (name === 'timeStart') return `Start${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'timeEnd') return `End${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'durationOverride') return `Duration${tooltip ? `: ${tooltip}` : ''}`;
return '';
}
export default function TimeInput(props: TimeInputProps) {
const {
id,
name,
submitHandler,
time = 0,
delay = 0,
placeholder,
validationHandler,
previousEnd = 0,
warning,
} = props;
const { id, name, submitHandler, time = 0, delay = 0, placeholder, previousEnd = 0 } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -103,15 +92,12 @@ export default function TimeInput(props: TimeInputProps) {
// check if time is different from before
if (newValMillis === time) return false;
// validate with parent
if (!validationHandler(name, newValMillis)) return false;
// update entry
submitHandler(name, newValMillis);
return true;
},
[name, previousEnd, submitHandler, time, validationHandler],
[name, previousEnd, submitHandler, time],
);
/**
@@ -171,11 +157,11 @@ export default function TimeInput(props: TimeInputProps) {
const isDelayed = delay !== 0;
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null, warning ? style.warn : null]);
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null]);
const TooltipLabel = useMemo(() => {
return ButtonTooltip(name, warning);
}, [name, warning]);
return ButtonTooltip(name, '');
}, [name]);
const ButtonText = useMemo(() => {
return ButtonInitial(name);
@@ -212,6 +198,7 @@ export default function TimeInput(props: TimeInputProps) {
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
/>
</InputGroup>
);
@@ -10,6 +10,7 @@ $progress-bar-br: 3px;
border-radius: $progress-bar-br;
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
display: flex;
overflow: hidden;
&--hidden {
display: none;
@@ -31,7 +32,6 @@ $progress-bar-br: 3px;
position: absolute;
height: inherit;
right: 0;
border-radius: $progress-bar-br;
width: 100%;
}
@@ -39,12 +39,10 @@ $progress-bar-br: 3px;
position: absolute;
height: inherit;
right: 0;
border-radius: 0 $progress-bar-br $progress-bar-br 0;
}
.multiprogress-bar__bg-danger {
position: absolute;
height: inherit;
right: 0;
border-radius: 0 $progress-bar-br $progress-bar-br 0;
}
@@ -3,7 +3,7 @@ import { clamp } from '../../utils/math';
import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: number;
now: number | null;
complete: number;
normalColor: string;
warning: number;
@@ -17,17 +17,27 @@ interface MultiPartProgressBar {
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
const percentComplete = 100 - clamp(100 - (Math.max(now, 0) * 100) / complete, 0, 100);
const percentComplete = 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
const dangerWidth = clamp((danger / complete) * 100, 0, 100);
const warningWidth = clamp((warning / complete) * 100, 0, 100);
return (
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div className='multiprogress-bar__bg-warning' style={{ width: `${warningWidth}%`, backgroundColor: warningColor }} />
<div className='multiprogress-bar__bg-danger' style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }} />
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
{now !== null && (
<>
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
<div
className='multiprogress-bar__bg-warning'
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
/>
<div
className='multiprogress-bar__bg-danger'
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
/>
<div className='multiprogress-bar__indicator' style={{ width: `${percentComplete}%` }} />
</>
)}
</div>
);
}
@@ -10,10 +10,6 @@ $icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 48px;
.mirror {
transform: rotate(180deg);
}
.buttonContainer {
display: flex;
flex-direction: column;
@@ -12,7 +12,6 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useViewOptionsStore } from '../../stores/viewOptions';
import RenameClientModal from './rename-client-modal/RenameClientModal';
@@ -23,18 +22,19 @@ function NavigationMenu() {
const location = useLocation();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const { toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false));
const { isOpen, onOpen, onClose } = useDisclosure();
const toggleMenu = () => setShowMenu((prev) => !prev);
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen });
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
@@ -63,7 +63,7 @@ function NavigationMenu() {
};
return createPortal(
<div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}>
<div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal isOpen={isOpen} onClose={onClose} />
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
@@ -21,7 +21,6 @@ interface ScheduleProviderProps {
events: OntimeEvent[];
selectedEventId: string | null;
isBackstage?: boolean;
eventsPerPage?: number;
time?: number;
}
@@ -30,7 +29,6 @@ export const ScheduleProvider = ({
events,
selectedEventId,
isBackstage = false,
eventsPerPage = 7,
time = 10,
}: PropsWithChildren<ScheduleProviderProps>) => {
const [visiblePage, setVisiblePage] = useState(0);
@@ -39,6 +37,7 @@ export const ScheduleProvider = ({
// look for overrides from views
const hidePast = isStringBoolean(searchParams.get('hidePast'));
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? 7);
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
@@ -1,7 +1,12 @@
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
import { formatTime } from '../../utils/time';
import './Schedule.scss';
const formatOptions = {
format: 'hh:mm a',
};
interface ScheduleItemProps {
selected: 'past' | 'now' | 'future';
timeStart: number;
@@ -14,19 +19,10 @@ interface ScheduleItemProps {
}
export default function ScheduleItem(props: ScheduleItemProps) {
const {
selected,
timeStart,
timeEnd,
title,
presenter,
backstageEvent,
colour,
skip,
} = props;
const { selected, timeStart, timeEnd, title, presenter, backstageEvent, colour, skip } = props;
const start = formatTime(timeStart, { format: 'hh:mm' });
const end = formatTime(timeEnd, { format: 'hh:mm' });
const start = formatTime(timeStart, formatOptions);
const end = formatTime(timeEnd, formatOptions);
const userColour = colour !== '' ? colour : '';
const selectStyle = `entry--${selected}`;
@@ -34,12 +30,15 @@ export default function ScheduleItem(props: ScheduleItemProps) {
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
<div className='entry-times'>
<span className='entry-colour' style={{ backgroundColor: userColour }} />
{`${start}${end} ${backstageEvent ? '*' : ''}`}
<div style={{ display: 'flex' }}>
<SuperscriptTime time={start} />
{' → '}
<SuperscriptTime time={end} />
{backstageEvent ? '*' : ''}
</div>
</div>
<div className='entry-title'>{title}</div>
{presenter && (
<div className='entry-presenter'>{presenter}</div>
)}
{presenter && <div className='entry-presenter'>{presenter}</div>}
</li>
);
}
@@ -13,12 +13,11 @@ export default function ScheduleNav({ className }: ScheduleNavProps) {
<div className={`schedule-nav ${className}`}>
{numPages > 1 &&
[...Array(numPages).keys()].map((i) => (
<div
key={i}
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
/>
),
)}
<div
key={i}
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
/>
))}
</div>
);
}
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router-dom';
import { Input, Select, Switch } from '@chakra-ui/react';
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
import { isStringBoolean } from '../../utils/viewUtils';
@@ -9,16 +9,22 @@ interface EditFormInputProps {
paramField: ParamField;
}
export default function ParamInput({ paramField }: EditFormInputProps) {
export default function ParamInput(props: EditFormInputProps) {
const [searchParams] = useSearchParams();
const { id, type } = paramField;
const { paramField } = props;
const { id, type, defaultValue } = paramField;
if (type === 'option') {
const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || undefined;
const defaultOptionValue = optionFromParams || defaultValue;
return (
<Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}>
<Select
placeholder={defaultValue ? undefined : 'Select an option'}
variant='ontime'
name={id}
defaultValue={defaultOptionValue}
>
{Object.entries(paramField.values).map(([key, value]) => (
<option key={key} value={key}>
{value}
@@ -29,19 +35,38 @@ export default function ParamInput({ paramField }: EditFormInputProps) {
}
if (type === 'boolean') {
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) ?? false;
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
// checked value should be 'true', so it can be captured by the form event
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
}
if (type === 'number') {
const defaultNumberValue = searchParams.get(id) ?? '';
const { prefix, placeholder } = paramField;
const defaultNumberValue = searchParams.get(id) ?? defaultValue;
return <Input type='number' step='any' variant='ontime-filled' name={id} defaultValue={defaultNumberValue} />;
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input
type='number'
step='any'
variant='ontime-filled'
name={id}
defaultValue={defaultNumberValue}
placeholder={placeholder}
/>
</InputGroup>
);
}
const defaultStringValue = searchParams.get(id) ?? '';
const defaultStringValue = searchParams.get(id) ?? defaultValue;
const { prefix, placeholder } = paramField;
return <Input variant='ontime-filled' name={id} defaultValue={defaultStringValue} />;
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input name={id} defaultValue={defaultStringValue} placeholder={placeholder} />
</InputGroup>
);
}
@@ -1,5 +1,5 @@
import { FormEvent, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLocation, useSearchParams } from 'react-router-dom';
import {
Button,
Drawer,
@@ -12,11 +12,34 @@ import {
useDisclosure,
} from '@chakra-ui/react';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import ParamInput from './ParamInput';
import { ParamField } from './types';
import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
type SavedViewParams = Record<string, ViewParamsObj>;
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.map(({ defaultValue }) => String(defaultValue));
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) {
if (defaultValues.includes(value)) {
return newSearchParams;
}
newSearchParams.set(id, value);
return newSearchParams;
}
return newSearchParams;
}, new URLSearchParams());
};
interface EditFormDrawerProps {
paramFields: ParamField[];
}
@@ -24,6 +47,8 @@ interface EditFormDrawerProps {
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure();
const { pathname } = useLocation();
const [storedViewParams, setStoredViewParams] = useLocalStorage<SavedViewParams>('ontime-views', {});
useEffect(() => {
const isEditing = searchParams.get('edit');
@@ -33,36 +58,51 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
}
}, [searchParams, onOpen]);
const onEditDrawerClose = () => {
/**
* disabling this for now, this feature needs more testing
* - we seem to have a bug where this is conflicting with the aliases
* - I wonder if the logic below needs to be inside an effect,
* both localStorage and searchParams should trigger a component update when they change
useEffect(() => {
const viewParamsObjFromLocalStorage = storedViewParams[pathname];
if (viewParamsObjFromLocalStorage !== undefined) {
const defaultSearchParams = getURLSearchParamsFromObj(viewParamsObjFromLocalStorage);
setSearchParams(defaultSearchParams);
}
// linter is asking for `setSearchParams` & `storedViewParams` in the useEffect deps
// rule is disabled since adding `setSearchParams` & `storedViewParams` results in unnecessary re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
*/
const onCloseWithoutSaving = () => {
onClose();
searchParams.delete('edit');
setSearchParams(searchParams);
};
const clearParams = () => {
const resetParams = () => {
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
setSearchParams();
onClose();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
formEvent.preventDefault();
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = Object.entries(newParamsObject).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) {
newSearchParams.set(id, value);
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
return newSearchParams;
}
return newSearchParams;
}, new URLSearchParams());
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
setSearchParams(newSearchParams);
};
return (
<Drawer isOpen={isOpen} placement='right' onClose={onEditDrawerClose} size='lg'>
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader className={style.drawerHeader}>
@@ -85,10 +125,10 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
</DrawerBody>
<DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-ghosted' onClick={clearParams} type='reset'>
Clear
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
Reset
</Button>
<Button variant='ontime-subtle' onClick={onEditDrawerClose}>
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
Cancel
</Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
@@ -1,46 +1,56 @@
import { UserFields } from 'ontime-types';
import { TimeFormat } from 'ontime-types/src/definitions/core/TimeFormat.type';
import { ParamField } from './types';
export const TIME_FORMAT_OPTION: ParamField = {
export const getTimeOption = (timeFormat: TimeFormat): ParamField => ({
id: 'format',
title: '12 / 24 hour timer',
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
type: 'option',
values: { '12': '12 hour AM/PM', '24': '24 hour' },
};
defaultValue: timeFormat,
});
export const CLOCK_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
@@ -48,12 +58,14 @@ export const CLOCK_OPTIONS: ParamField[] = [
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
@@ -61,47 +73,94 @@ export const CLOCK_OPTIONS: ParamField[] = [
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
export const TIMER_OPTIONS: ParamField[] = [TIME_FORMAT_OPTION];
export const getTimerOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
@@ -109,12 +168,14 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
@@ -122,133 +183,162 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidemessages',
title: 'Hide Message Overlay',
description: 'Whether to hide the overlay from showing timer screen messages',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
];
export const LOWER_THIRDS_OPTIONS: ParamField[] = [
{
id: 'preset',
title: 'Preset',
description: 'Selects a style preset (0-1)',
type: 'number',
},
{
id: 'size',
title: 'Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 5)',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffffa (default)',
},
{
id: 'bg',
title: 'Text Background',
description: 'Text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Screen background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
},
{
id: 'fadeout',
title: 'Fadeout',
description: 'Time (in seconds) the lower third displays before fading out',
type: 'number',
placeholder: '3 (default)',
},
];
export const BACKSTAGE_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getBackstageOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
];
export const PUBLIC_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getPublicOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
];
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getStudioClockOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'seconds',
title: 'Show Seconds',
description: 'Shows seconds in clock',
type: 'boolean',
defaultValue: false,
},
];
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeFormat): ParamField[] => {
return [
TIME_FORMAT_OPTION,
getTimeOption(timeFormat),
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
@@ -290,5 +380,12 @@ export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
user9: userFields.user9 || 'user9',
},
},
{
id: 'shouldEdit',
title: 'Edit user field',
description: 'Allows editing an events user field by long pressing on it. Needs a selected highlighted field',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -4,9 +4,13 @@ type BaseField = {
description: string;
};
type OptionsField = { type: 'option'; values: Record<string, string> };
type StringField = { type: 'string' };
type BooleanField = { type: 'boolean' };
type NumberField = { type: 'number' };
type OptionsField = {
type: 'option';
values: Record<string, string>;
defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField);
@@ -25,7 +25,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
const [operatorAuth, setOperatorAuth] = useState(true);
useEffect(() => {
if (status === 'loading') return;
if (status === 'pending') return;
if (!data) return;
const previousEditor = sessionStorage.getItem(storageKeys.editor);
@@ -0,0 +1,33 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { HttpSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getHTTP, postHTTP } from '../api/ontimeApi';
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: httpPlaceholder,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
// we need to jump through some hoops because of the type op port
return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch };
}
export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
});
return { isPending, mutateAsync };
}
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { GetInfo } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { APP_INFO } from '../api/apiConstants';
@@ -6,7 +7,7 @@ import { getInfo } from '../api/ontimeApi';
import { ontimePlaceholderInfo } from '../models/Info';
export default function useInfo() {
const { data, status, isError, refetch } = useQuery({
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
queryKey: APP_INFO,
queryFn: getInfo,
placeholderData: ontimePlaceholderInfo,
@@ -16,5 +17,5 @@ export default function useInfo() {
networkMode: 'always',
});
return { data, status, isError, refetch };
return { data, status, isError, refetch, isFetching };
}
@@ -1,5 +1,4 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { OSCSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/apiConstants';
@@ -11,7 +10,10 @@ import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
queryFn: async () => {
const oscData = await getOSC();
return { ...oscData, portIn: String(oscData.portIn), portOut: String(oscData.portOut) };
},
placeholderData: oscPlaceholderSettings,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
@@ -19,25 +21,24 @@ export default function useOscSettings() {
networkMode: 'always',
});
// we need to jump through some hoops because of the type op port
return { data: data! as unknown as OSCSettings, status, isFetching, isError, refetch };
return { data: data ?? oscPlaceholderSettings, status, isFetching, isError, refetch };
}
export function useOscSettingsMutation() {
const { isLoading, mutateAsync } = useMutation({
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 { isLoading, mutateAsync };
return { isPending, mutateAsync };
}
export function usePostOscSubscriptions() {
const { isLoading, mutateAsync } = useMutation({
const { isPending, mutateAsync } = useMutation({
mutationFn: postOscSubscriptions,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isLoading, mutateAsync };
return { isPending, mutateAsync };
}
@@ -1,19 +1,29 @@
import { useQuery } from '@tanstack/react-query';
import { GetRundownCached } from 'ontime-types';
import { queryRefetchInterval } from '../../ontimeConfig';
import { RUNDOWN_TABLE } from '../api/apiConstants';
import { fetchRundown } from '../api/eventsApi';
import { RUNDOWN } from '../api/apiConstants';
import { fetchCachedRundown } from '../api/eventsApi';
const cachedRundownPlaceholder = { rundown: [], revision: -1 };
// TODO: can we leverage structural sharing to see if data has changed?
export default function useRundown() {
const { data, status, isError, refetch } = useQuery({
queryKey: RUNDOWN_TABLE,
queryFn: fetchRundown,
placeholderData: [],
const { data, status, isError, refetch, isFetching } = useQuery<GetRundownCached>({
queryKey: RUNDOWN,
queryFn: fetchCachedRundown,
placeholderData: cachedRundownPlaceholder,
retry: 5,
retryDelay: (attempt) => attempt * 2500,
refetchInterval: queryRefetchInterval,
networkMode: 'always',
// structuralSharing: (oldData: GetRundownCached | undefined, newData: GetRundownCached) => {
// if (oldData === undefined) {
// return cachedRundownPlaceholder;
// }
// const hasDataChanged = oldData?.revision === newData.revision;
// return hasDataChanged ? oldData : newData;
// },
});
return { data, status, isError, refetch };
return { data: data?.rundown ?? [], status, isError, refetch, isFetching };
}
@@ -6,7 +6,6 @@ export default function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
callback: ClickOutsideEventHandler,
) {
useEffect(() => {
function handleClick(event: MouseEvent) {
const element = ref?.current;
@@ -1,7 +1,7 @@
export default function useElectronEvent() {
const isElectron = window?.process?.type === 'renderer';
const sendToElectron = (channel: string, args?: string | Record<string, any>) => {
const sendToElectron = (channel: string, args?: string | Record<string, unknown>) => {
if (isElectron) {
window?.ipcRenderer.send(channel, args);
}
+72 -49
View File
@@ -1,9 +1,9 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { GetRundownCached, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
import { RUNDOWN } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import {
ReorderEntry,
@@ -36,7 +36,7 @@ export const useEventAction = () => {
// Fetch anyway, just to be sure
mutationFn: requestPostEvent,
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
@@ -67,8 +67,10 @@ export const useEventAction = () => {
after: options?.after,
};
const rundown = queryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
if (newEvent?.cue === undefined) {
newEvent.cue = getCueCandidate(queryClient.getQueryData(RUNDOWN_TABLE) || [], options?.after);
newEvent.cue = getCueCandidate(rundown, options?.after);
}
// hard coding duration value to be as expected for now
@@ -78,7 +80,6 @@ export const useEventAction = () => {
}
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
if (previousEvent !== undefined && previousEvent.type === 'event') {
newEvent.timeStart = previousEvent.timeEnd;
@@ -100,7 +101,7 @@ export const useEventAction = () => {
// @ts-expect-error -- we know that the object is well formed now
await _addEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error fetching data', error);
logAxiosError('Failed adding event', error);
}
},
[_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd],
@@ -115,25 +116,35 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async (newEvent) => {
// cancel ongoing queries
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, newEvent.id]);
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvent = queryClient.getQueryData([RUNDOWN_TABLE_KEY, newEvent.id]);
// optimistically update object
queryClient.setQueryData([RUNDOWN_TABLE_KEY, newEvent.id], newEvent);
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const index = optimisticRundown.findIndex((event) => event.id === newEvent.id);
if (index > -1) {
// @ts-expect-error -- we expect the event types to match
optimisticRundown[index] = { ...optimisticRundown[index], ...newEvent };
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
}
// Return a context with the previous and new events
return { previousEvent, newEvent };
return { previousData, newEvent };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: async () => {
await queryClient.invalidateQueries([RUNDOWN_TABLE_KEY]);
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
@@ -161,28 +172,37 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async (eventId) => {
// cancel ongoing queries
await queryClient.cancelQueries([RUNDOWN_TABLE_KEY, eventId]);
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const filtered = [...(previousEvents as OntimeRundown)].filter((e) => e.id !== eventId);
if (previousData) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const index = optimisticRundown.findIndex((event) => event.id === eventId);
if (index > -1) {
optimisticRundown.splice(index, 1);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, filtered);
queryClient.setQueryData(RUNDOWN, {
rundown: optimisticRundown,
revision: -1,
});
}
}
// Return a context with the previous and new events
return { previousEvents };
return { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
@@ -210,26 +230,26 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async () => {
// cancel ongoing queries
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, []);
queryClient.setQueryData(RUNDOWN, { rundown: [], revision: -1 });
// Return a context with the previous and new events
return { previousEvents };
return { previousData };
},
// Mutation fails, rollback undos optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
@@ -253,7 +273,7 @@ export const useEventAction = () => {
mutationFn: requestApplyDelay,
// Mutation finished, failed or successful
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
@@ -281,30 +301,32 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async (data) => {
// cancel ongoing queries
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData(RUNDOWN_TABLE);
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
const e = [...(previousEvents as OntimeRundown)];
const [reorderedItem] = e.splice(data.from, 1);
e.splice(data.to, 0, reorderedItem);
if (previousData) {
// optimistically update object
const optimisticRundown = [...previousData.rundown];
const [reorderedItem] = optimisticRundown.splice(data.from, 1);
optimisticRundown.splice(data.to, 0, reorderedItem);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, e);
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
// Return a context with the previous and new events
return { previousEvents };
return { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
@@ -337,31 +359,32 @@ export const useEventAction = () => {
// we optimistically update here
onMutate: async ({ from, to }) => {
// cancel ongoing queries
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const previousData = queryClient.getQueryData<GetRundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const fromEventIndex = previousData.rundown.findIndex((event) => event.id === from);
const toEventIndex = previousData.rundown.findIndex((event) => event.id === to);
const fromEventIndex = rundown.findIndex((event) => event.id === from);
const toEventIndex = rundown.findIndex((event) => event.id === to);
const optimisticRundown = swapOntimeEvents(previousData.rundown, fromEventIndex, toEventIndex);
const previousEvents = swapOntimeEvents(rundown, fromEventIndex, toEventIndex);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, previousEvents);
queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 });
}
// Return a context with the previous events
return { previousEvents };
return { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
+11 -29
View File
@@ -1,10 +1,4 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
export type TLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none';
@@ -26,13 +20,13 @@ const LOG_LEVEL: Record<TLogLevel, number> = {
};
const useFitText = ({
logLevel: logLevelOption = 'info',
maxFontSize = 100,
minFontSize = 20,
onFinish,
onStart,
resolution = 5,
}: TOptions = {}) => {
logLevel: logLevelOption = 'info',
maxFontSize = 100,
minFontSize = 20,
onFinish,
onStart,
resolution = 5,
}: TOptions = {}) => {
const logLevel = LOG_LEVEL[logLevelOption];
const initState = useCallback(() => {
@@ -112,8 +106,7 @@ const useFitText = ({
const isWithinResolution = Math.abs(fontSize - fontSizePrev) <= resolution;
const isOverflow =
!!ref.current &&
(ref.current.scrollHeight > ref.current.offsetHeight ||
ref.current.scrollWidth > ref.current.offsetWidth);
(ref.current.scrollHeight > ref.current.offsetHeight || ref.current.scrollWidth > ref.current.offsetWidth);
const isFailed = isOverflow && fontSize === fontSizePrev;
const isAsc = fontSize > fontSizePrev;
@@ -123,9 +116,7 @@ const useFitText = ({
if (isFailed) {
isCalculatingRef.current = false;
if (logLevel <= LOG_LEVEL.info) {
console.info(
`[use-fit-text] reached \`minFontSize = ${minFontSize}\` without fitting text`,
);
console.info(`[use-fit-text] reached \`minFontSize = ${minFontSize}\` without fitting text`);
}
} else if (isOverflow) {
setState({
@@ -160,16 +151,7 @@ const useFitText = ({
fontSizeMin: newMin,
fontSizePrev: fontSize,
});
}, [
calcKey,
fontSize,
fontSizeMax,
fontSizeMin,
fontSizePrev,
onFinish,
ref,
resolution,
]);
}, [calcKey, fontSize, fontSizeMax, fontSizeMin, fontSizePrev, onFinish, ref, resolution]);
return { fontSize: `${fontSize}%`, ref };
};
@@ -21,7 +21,7 @@ interface UseFollowComponentProps {
scrollRef: MutableRefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
setScrollFlag?: () => void;
setScrollFlag?: (newValue: boolean) => void;
}
export default function useFollowComponent(props: UseFollowComponentProps) {
@@ -34,14 +34,15 @@ export default function useFollowComponent(props: UseFollowComponentProps) {
}
if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
setScrollFlag?.();
scrollToComponent(
followRef as MutableRefObject<HTMLElement>,
scrollRef as MutableRefObject<HTMLElement>,
topOffset,
);
setScrollFlag?.(false);
});
}
@@ -1,3 +1,5 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-nocheck -- working on it
import { useCallback, useEffect, useState } from 'react';
interface WebkitDocument extends Document {
+1 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef } from 'react';
/**
* @description utility hook to around setInterval
+34 -41
View File
@@ -1,53 +1,46 @@
import { useEffect, useState } from 'react';
import { useSyncExternalStore } from 'react';
/**
* @description utility hook to handle state in local storage
* @param key
* @param initialValue
*/
export const useLocalStorage = <T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] => {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(`ontime-${key}`);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const STORAGE_EVENT = 'ontime-storage';
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.storageArea === window.localStorage && event.key === key) {
try {
const newValue = event.newValue ? JSON.parse(event.newValue) : initialValue;
setStoredValue(newValue);
} catch (_) {
/* empty */
}
}
};
function getSnapshot(key: string): string | null {
try {
return window.localStorage.getItem(`ontime-${key}`);
} catch {
return null;
}
}
window.addEventListener('storage', handleStorageChange);
function getParsedJson<T>(localStorageValue: string | null, initialValue: T): T {
try {
return localStorageValue ? JSON.parse(localStorageValue) : initialValue;
} catch {
return initialValue;
}
}
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [initialValue, key]);
export const useLocalStorage = <T>(key: string, initialValue: T) => {
const localStorageValue = useSyncExternalStore(subscribe, () => getSnapshot(key));
const parsedLocalStorageValue = getParsedJson(localStorageValue, initialValue);
/**
* @description Set value to local storage
* @param value
*/
const setValue = (value: T | ((val: T) => T)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value;
const setLocalStorageValue = (value: T | ((val: T) => T)) => {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(parsedLocalStorageValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
} catch (error) {
console.log(error);
}
localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
window.dispatchEvent(new StorageEvent(STORAGE_EVENT));
};
return [storedValue, setValue];
return [parsedLocalStorageValue, setLocalStorageValue] as const;
};
function subscribe(callback: () => void) {
window.addEventListener(STORAGE_EVENT, callback);
return () => {
window.removeEventListener(STORAGE_EVENT, callback);
};
}
@@ -0,0 +1,64 @@
import { MouseEvent, SyntheticEvent, TouchEvent, useMemo, useRef } from 'react';
type LongPressOptions = {
threshold?: number;
onStart?: (e: SyntheticEvent) => void;
onFinish?: (e: SyntheticEvent) => void;
onCancel?: (e: SyntheticEvent) => void;
};
type LongPressFns = {
onMouseDown: (e: MouseEvent) => void;
onMouseUp: (e: MouseEvent) => void;
onMouseLeave: (e: MouseEvent) => void;
onTouchStart: (e: TouchEvent) => void;
onTouchEnd: (e: TouchEvent) => void;
};
export default function useLongPress(callback: () => void, options: LongPressOptions = {}): LongPressFns {
const { threshold = 400, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false);
const isPressed = useRef(false);
const timerId = useRef<NodeJS.Timer>();
return useMemo(() => {
const start = (event: SyntheticEvent) => {
if (onStart) {
onStart(event);
}
isPressed.current = true;
timerId.current = setTimeout(() => {
callback();
isLongPressActive.current = true;
}, threshold);
};
const cancel = (event: SyntheticEvent) => {
if (isLongPressActive.current) {
if (onFinish) {
onFinish(event);
}
} else if (isPressed.current) {
if (onCancel) {
onCancel(event);
}
}
isLongPressActive.current = false;
isPressed.current = false;
if (timerId.current) {
clearTimeout(timerId.current);
}
};
return {
onMouseDown: start,
onMouseUp: cancel,
onMouseLeave: cancel,
onTouchStart: start,
onTouchEnd: cancel,
};
}, [callback, threshold, onCancel, onFinish, onStart]);
}
+5 -2
View File
@@ -27,6 +27,7 @@ export const useMessageControl = () => {
timerMessage: state.timerMessage,
publicMessage: state.publicMessage,
lowerMessage: state.lowerMessage,
externalMessage: state.externalMessage,
onAir: state.onAir,
});
@@ -40,6 +41,8 @@ export const setMessage = {
publicVisible: (payload: boolean) => socketSendJson('set-public-message-visible', payload),
lowerText: (payload: string) => socketSendJson('set-lower-message-text', payload),
lowerVisible: (payload: boolean) => socketSendJson('set-lower-message-visible', payload),
externalText: (payload: string) => socketSendJson('set-external-message-text', payload),
externalVisible: (payload: boolean) => socketSendJson('set-external-message-visible', payload),
onAir: (payload: boolean) => socketSendJson('set-onAir', payload),
timerBlink: (payload: boolean) => socketSendJson('set-timer-blink', payload),
timerBlackout: (payload: boolean) => socketSendJson('set-timer-blackout', payload),
@@ -72,8 +75,8 @@ export const setPlayback = {
reload: () => {
socketSendJson('reload');
},
delay: (amount: number) => {
socketSendJson('delay', amount);
addTime: (amount: number) => {
socketSendJson('addtime', amount);
},
};
+11 -24
View File
@@ -1,26 +1,13 @@
export const httpPlaceholder = {
onLoad: {
url: '',
enabled: false,
},
onStart: {
url: '',
enabled: false,
},
onUpdate: {
url: '',
enabled: false,
},
onPause: {
url: '',
enabled: false,
},
onStop: {
url: '',
enabled: false,
},
onFinish: {
url: '',
enabled: false,
import { HttpSettings } from 'ontime-types';
export const httpPlaceholder: HttpSettings = {
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onUpdate: [],
onPause: [],
onStop: [],
onFinish: [],
},
};
+22 -16
View File
@@ -1,19 +1,25 @@
import { Settings } from 'ontime-types';
import { GetInfo, OSCSettings } from 'ontime-types';
type NetworkInterfaceType = {
name: string;
address: string;
};
export type InfoType = {
networkInterfaces: NetworkInterfaceType[];
settings: Pick<Settings, 'version' | 'serverPort'>;
};
export const ontimePlaceholderInfo: InfoType = {
networkInterfaces: [],
settings: {
version: 2,
serverPort: 4001,
export const oscPlaceholderSettings: OSCSettings = {
portIn: 0,
portOut: 0,
targetIP: '',
enabledIn: false,
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
};
export const ontimePlaceholderInfo: GetInfo = {
networkInterfaces: [],
version: '2.0.0',
serverPort: 4001,
osc: oscPlaceholderSettings,
cssOverride: '',
};
@@ -2,7 +2,7 @@ import { Settings } from 'ontime-types';
export const ontimePlaceholderSettings: Settings = {
app: 'ontime',
version: 2,
version: '2.0.0',
serverPort: 4001,
editorKey: null,
operatorKey: null,
+1 -1
View File
@@ -3,7 +3,7 @@ import { QueryClient } from '@tanstack/react-query';
export const ontimeQueryClient = new QueryClient({
defaultOptions: {
queries: {
cacheTime: 1000 * 60 * 10, // 10 min
gcTime: 1000 * 60 * 10, // 10 min
},
},
});
+5 -1
View File
@@ -3,7 +3,7 @@ import { Playback, RuntimeStore } from 'ontime-types';
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
export const runtimeStorePlaceholder = {
export const runtimeStorePlaceholder: RuntimeStore = {
timer: {
clock: 0,
current: null,
@@ -33,6 +33,10 @@ export const runtimeStorePlaceholder = {
text: '',
visible: false,
},
externalMessage: {
text: '',
visible: false,
},
onAir: false,
loaded: {
numEvents: 0,
@@ -1,5 +1,6 @@
import { resolvePath } from 'react-router-dom';
import { validateAlias, generateURLFromAlias, getAliasRoute } from '../aliases';
import { generateURLFromAlias, getAliasRoute, validateAlias } from '../aliases';
describe('An alias fails if incorrect', () => {
const testsToFail = [
@@ -12,7 +12,7 @@ test('Clamps a set of numbers correctly', () => {
{ num: -50, min: 0, max: 0, result: 0 },
{ num: 50.5, min: 0, max: 100, result: 50.5 },
{ num: 50, min: 0, max: 20.32, result: 20.32 },
{ num: 10, min: 20.32, max: 40, result: 20.32 }
{ num: 10, min: 20.32, max: 40, result: 20.32 },
];
testCases.forEach((t) => expect(clamp(t.num, t.min, t.max)).toBe(t.result));
@@ -1,4 +1,4 @@
import { isIPAddress, isOnlyNumbers } from '../regex';
import { isIPAddress, isOnlyNumbers, startsWithHttp } from '../regex';
describe('simple tests for regex', () => {
test('isOnlyNumbers', () => {
@@ -24,4 +24,16 @@ describe('simple tests for regex', () => {
expect(isIPAddress.test(t)).toBe(false);
});
});
test('startsWithHttp', () => {
const right = ['http://test'];
const wrong = ['https://test', 'testing', '123.0.1'];
right.forEach((t) => {
expect(startsWithHttp.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(startsWithHttp.test(t)).toBe(false);
});
});
});
@@ -1,4 +1,4 @@
import { cx } from '../styleUtils';
import { cx, getAccessibleColour } from '../styleUtils';
import style from './styleUtils.module.scss';
@@ -13,3 +13,24 @@ describe('cx()', () => {
expect(merged).toMatchSnapshot();
});
});
describe('getAccessibleColour()', () => {
it('handles named colours', () => {
const colour = 'red';
const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#FF0000FF');
expect(color).toBe('#fffffa');
});
it('handles hex colours', () => {
const colour = '#0F0';
const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#00FF00FF');
expect(color).toBe('black');
});
it('handles transparens', () => {
const colour = '#0F08';
const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#0C940CFF');
expect(color).toBe('#fffffa');
});
});
@@ -26,4 +26,14 @@ describe('formatTime()', () => {
const time = formatTime(ms);
expect(time).toStrictEqual('...');
});
it('shows 12h format without times', () => {
const ms = 13 * 60 * 60 * 1000;
const options = {
showSeconds: false,
format: 'hh:mm a',
};
const time = formatTime(ms, options, () => '12');
expect(time).toStrictEqual('01:00 PM');
});
});
+1
View File
@@ -1,2 +1,3 @@
export const isOnlyNumbers = /^\d+$/;
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
export const startsWithHttp = /^http:\/\//;
+1 -1
View File
@@ -129,7 +129,7 @@ export const socketSend = (message: any) => {
}
};
export const socketSendJson = (type: string, payload?: any) => {
export const socketSendJson = (type: string, payload?: unknown) => {
socketSend(
JSON.stringify({
type,
+5 -3
View File
@@ -13,13 +13,15 @@ type ColourCombination = {
export const getAccessibleColour = (bgColour?: string): ColourCombination => {
if (bgColour) {
try {
const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa';
return { backgroundColor: bgColour, color: textColor };
const originalColour = Color(bgColour);
const backgroundColorMix = originalColour.alpha(1).mix(Color('#1a1a1a'), 1 - originalColour.alpha());
const textColor = backgroundColorMix.isLight() ? 'black' : '#fffffa';
return { backgroundColor: backgroundColorMix.hexa(), color: textColor };
} catch (_error) {
/* we do not handle errors here */
}
}
return { backgroundColor: '#000', color: '#fffffa' };
return { backgroundColor: '#1a1a1a', color: '#fffffa' };
};
/**
@@ -1,44 +1 @@
export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride';
/**
* @description Checks which field the value relates to
*/
export const handleTimeEntry = (
field: TimeEntryField,
val: number,
timeStart: number,
timeEnd: number,
): { start: number; end: number; durationOverride: boolean } => {
let start = timeStart;
let end = timeEnd;
let durationOverride = false;
if (field === 'timeStart') {
start = val;
} else if (field === 'timeEnd') {
end = val;
} else {
durationOverride = field === 'durationOverride';
}
return { start, end, durationOverride };
};
/**
* @description Validates time entry
*/
export const validateEntry = (
field: TimeEntryField,
value: number,
timeStart: number,
timeEnd: number,
): { value: boolean; warnings: { start?: string; end?: string; duration?: string } } => {
const validate = { value: true, warnings: { start: '', end: '', duration: '' } };
const { start, end } = handleTimeEntry(field, value, timeStart, timeEnd);
if (end < start) {
validate.warnings.start = 'Start time later than end time';
}
return validate;
};
+2 -3
View File
@@ -13,9 +13,8 @@ declare global {
};
process: {
type: string;
}
};
}
}
// eslint-disable-next-line import/no-anonymous-default-export
export default {}
export default {};
+1 -1
View File
@@ -6,6 +6,6 @@ import 'vitest';
// https://github.com/testing-library/jest-dom/issues/123
declare global {
namespace Vi {
interface Assertion<T = any> extends TestingLibraryMatchers<T, void> {}
type Assertion<T = any> = TestingLibraryMatchers<T, void>;
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ const withAlias = <P extends object>(Component: ComponentType<P>) => {
}
}, [data, searchParams, navigate, location]);
return <Component {...props} />;
return <Component {...(props as P)} />;
};
};
@@ -1,4 +1,4 @@
import { Input } from '@chakra-ui/react';
import { IconButton, Input } from '@chakra-ui/react';
import { IoEye } from '@react-icons/all-files/io5/IoEye';
import { IoEyeOffOutline } from '@react-icons/all-files/io5/IoEyeOffOutline';
@@ -13,13 +13,14 @@ interface InputRowProps {
placeholder: string;
text: string;
visible?: boolean;
readonly?: boolean;
actionHandler: (action: string, payload: object) => void;
changeHandler: (newValue: string) => void;
className?: string;
}
export default function InputRow(props: InputRowProps) {
const { label, placeholder, text, visible, actionHandler, changeHandler, className } = props;
const { label, placeholder, text, visible, actionHandler, changeHandler, className, readonly } = props;
const handleInputChange = (newValue: string) => {
changeHandler(newValue);
@@ -33,19 +34,31 @@ export default function InputRow(props: InputRowProps) {
<Input
size='sm'
variant='ontime-filled'
readOnly={readonly}
disabled={readonly}
value={text}
onChange={(event) => handleInputChange(event.target.value)}
placeholder={placeholder}
/>
<TooltipActionBtn
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
{readonly ? (
<IconButton
size='sm'
isDisabled
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
aria-label={`Toggle ${label}`}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
/>
) : (
<TooltipActionBtn
clickHandler={() => actionHandler('update', { field: 'isPublic', value: !visible })}
tooltip={visible ? 'Make invisible' : 'Make visible'}
aria-label={`Toggle ${label}`}
openDelay={tooltipDelayMid}
icon={visible ? <IoEye size='18px' /> : <IoEyeOffOutline size='18px' />}
variant={visible ? 'ontime-filled' : 'ontime-subtle'}
size='sm'
/>
)}
</div>
</div>
);
@@ -27,7 +27,3 @@
color: $action-text-color;
}
}
.padTop {
margin-top: $section-spacing;
}
@@ -34,34 +34,48 @@ export default function MessageControl() {
actionHandler={() => setMessage.lowerVisible(!data.lowerMessage.visible)}
/>
<InputRow
label='Timer message'
placeholder='Shown in stage timer'
label='Timer'
placeholder='Message shown in stage timer'
text={data.timerMessage.text || ''}
visible={data.timerMessage.visible || false}
changeHandler={(newValue) => setMessage.presenterText(newValue)}
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
/>
<div className={style.buttonSection}>
<label className={style.label}>Timer message blink</label>
<label className={style.label}>Blackout timer screens</label>
<Button
size='sm'
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
variant={data.timerMessage.timerBlink ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.timerMessage.timerBlink ? <IoSunny size='24px' /> : <IoSunnyOutline size='24px' />}
leftIcon={data.timerMessage.timerBlink ? <IoSunny size='1rem' /> : <IoSunnyOutline size='1rem' />}
onClick={() => setMessage.timerBlink(!data.timerMessage.timerBlink)}
data-testid='toggle timer blink'
/>
>
Blink message
</Button>
<Button
size='sm'
className={style.blackoutButton}
variant={data.timerMessage.timerBlackout ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.timerMessage.timerBlackout ? <IoEye size='24px' /> : <IoEyeOffOutline size='24px' />}
leftIcon={data.timerMessage.timerBlackout ? <IoEye size='1rem' /> : <IoEyeOffOutline size='1rem' />}
onClick={() => setMessage.timerBlackout(!data.timerMessage.timerBlackout)}
data-testid='toggle timer blackout'
/>
>
Blackout screen
</Button>
</div>
<div className={`${style.onAirSection} ${style.padTop}`}>
<InputRow
label='External Message'
placeholder='-'
readonly
text={data.externalMessage.text || ''}
visible={data.externalMessage.visible || false}
changeHandler={() => undefined}
actionHandler={() => undefined}
/>
<div className={style.onAirSection}>
<label className={style.label}>Toggle On Air state</label>
<Button
size='sm'
variant={data.onAir ? 'ontime-filled' : 'ontime-subtle'}
leftIcon={data.onAir ? <IoMicSharp size='24px' /> : <IoMicOffOutline size='24px' />}
onClick={() => setMessage.onAir(!data.onAir)}
@@ -86,22 +86,22 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
)}
<div className={style.btn}>
<Tooltip label='Remove 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-1)} disabled={disableButtons} aspect='square'>
<TapButton onClick={() => setPlayback.addTime(-60)} disabled={disableButtons} aspect='square'>
-1
</TapButton>
</Tooltip>
<Tooltip label='Add 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(1)} disabled={disableButtons} aspect='square'>
<TapButton onClick={() => setPlayback.addTime(60)} disabled={disableButtons} aspect='square'>
+1
</TapButton>
</Tooltip>
<Tooltip label='Remove 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-5)} disabled={disableButtons} aspect='square'>
<TapButton onClick={() => setPlayback.addTime(-5 * 60)} disabled={disableButtons} aspect='square'>
-5
</TapButton>
</Tooltip>
<Tooltip label='Add 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(+5)} disabled={disableButtons} aspect='square'>
<TapButton onClick={() => setPlayback.addTime(+5 * 60)} disabled={disableButtons} aspect='square'>
+5
</TapButton>
</Tooltip>
@@ -5,6 +5,7 @@ import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundo
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { getAccessibleColour } from '../../common/utils/styleUtils';
import BlockRow from './cuesheet-table-elements/BlockRow';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
@@ -24,10 +25,7 @@ interface CuesheetProps {
}
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
const followSelected = useCuesheetSettings((state) => state.followSelected);
const showSettings = useCuesheetSettings((state) => state.showSettings);
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
const { followSelected, showSettings, showDelayBlock, showPrevious } = useCuesheetSettings();
const [columnVisibility, setColumnVisibility] = useLocalStorage('table-hidden', {});
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
@@ -66,7 +64,9 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
setColumnSizing({});
};
const headerGroups = table.getHeaderGroups;
const headerGroups = table.getHeaderGroups();
const rowModel = table.getRowModel();
const allLeafColumns = table.getAllLeafColumns();
let eventIndex = 0;
let isPast = Boolean(selectedId);
@@ -75,7 +75,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
<>
{showSettings && (
<CuesheetTableSettings
columns={table.getAllLeafColumns()}
columns={allLeafColumns}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
@@ -85,7 +85,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
<table className={style.cuesheet}>
<CuesheetHeader headerGroups={headerGroups} />
<tbody>
{table.getRowModel().rows.map((row) => {
{rowModel.rows.map((row) => {
const key = row.original.id;
const isSelected = selectedId === key;
if (isSelected) {
@@ -121,8 +121,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
} else if (row.original.colour) {
try {
// the colour is user defined and might be invalid
const colour = new Color(row.original.colour).alpha(0.25);
rowBgColour = colour.hsl().string();
const accessibleBackgroundColor = Color(getAccessibleColour(row.original.colour).backgroundColor);
rowBgColour = accessibleBackgroundColor.fade(0.75).hexa();
} catch (_error) {
/* we do not handle errors here */
}
@@ -19,6 +19,6 @@
& > * {
border: 1px solid $white-10;
border-radius: 4px;
border-radius: 3px;
}
}
}
@@ -8,6 +8,7 @@ import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields';
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
import Cuesheet from './Cuesheet';
import { makeCuesheetColumns } from './cuesheetCols';
@@ -140,6 +141,7 @@ export default function CuesheetWrapper() {
return (
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetTableHeader handleExport={handleOpenModal} featureData={featureData} />
<CuesheetProgress />
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
<ExportModal isOpen={isModalOpen} onClose={onModalClose} />
</div>
@@ -0,0 +1,3 @@
.progressOverride {
height: 1rem;
}
@@ -0,0 +1,24 @@
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import { useTimer } from '../../../common/hooks/useSocket';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import styles from "./CuesheetProgress.module.scss"
export default function CuesheetProgress() {
const { data } = useViewSettings();
const timer = useTimer();
const totalTime = (timer.duration ?? 0) + (timer.addedTime ?? 0);
return (
<MultiPartProgressBar
now={timer.current}
complete={totalTime}
normalColor={data!.normalColor}
warning={data!.warningThreshold}
warningColor={data!.warningColor}
danger={data!.dangerThreshold}
dangerColor={data!.dangerColor}
className={styles.progressOverride}
/>
);
}
@@ -23,7 +23,7 @@ import { SortableCell } from './SortableCell';
import style from '../Cuesheet.module.scss';
interface CuesheetHeaderProps {
headerGroups: () => HeaderGroup<OntimeRundownEntry>[];
headerGroups: HeaderGroup<OntimeRundownEntry>[];
}
function CuesheetHeader(props: CuesheetHeaderProps) {
@@ -75,7 +75,7 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
return (
<thead className={style.tableHeader}>
{headerGroups().map((headerGroup) => {
{headerGroups.map((headerGroup) => {
const key = headerGroup.id;
return (
@@ -1,6 +0,0 @@
interface CuesheetRowProps {
row: OntimeRundownEntry;
isSelected: boolean;
}
function CuesheetRow() {}
@@ -19,8 +19,8 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false);
const bgColour = colour;
const textColour = getAccessibleColour(bgColour);
const textColour = getAccessibleColour(colour);
const bgColour = textColour.backgroundColor;
useLayoutEffect(() => {
const observer = new IntersectionObserver(
@@ -55,7 +55,7 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
ref={selectedRef ?? ownRef}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour.color }}>
{eventIndex}
</td>
{isVisible ? children : null}
@@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, ReactNode } from 'react';
import { Button, Checkbox, Switch } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
@@ -22,12 +22,14 @@ interface CuesheetTableSettingsProps {
function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const showPrevious = useCuesheetSettings((state) => state.showPrevious);
const togglePreviousVisibility = useCuesheetSettings((state) => state.togglePreviousVisibility);
const showDelayBlock = useCuesheetSettings((state) => state.showDelayBlock);
const toggleDelayVisibility = useCuesheetSettings((state) => state.toggleDelayVisibility);
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
const toggleDelayedTimes = useCuesheetSettings((state) => state.toggleDelayedTimes);
const {
showPrevious,
toggleDelayVisibility,
showDelayBlock,
showDelayedTimes,
toggleDelayedTimes,
togglePreviousVisibility,
} = useCuesheetSettings();
return (
<div className={style.tableSettings}>
@@ -44,7 +46,7 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
defaultChecked={visible}
onChange={column.getToggleVisibilityHandler()}
/>
{columnHeader}
{columnHeader as ReactNode}
</label>
);
})}
@@ -9,7 +9,7 @@ import { millisToString } from 'ontime-utils';
* @return {string}
*/
export const parseField = (field: keyof OntimeRundown, data: unknown): string => {
export const parseField = <T extends OntimeEntryCommonKeys>(field: T, data: unknown): string => {
let val;
switch (field) {
case 'timeStart':
@@ -96,6 +96,7 @@ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userF
rundown.forEach((entry) => {
const row: string[] = [];
// @ts-expect-error -- not sure how to type this
fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
data.push(row);
});
@@ -5,6 +5,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
import MenuBar from '../menu/MenuBar';
import AboutModal from '../modals/about-modal/AboutModal';
import QuickStart from '../modals/quick-start/QuickStart';
import SheetsModal from '../modals/sheets-modal/SheetsModal';
import UploadModal from '../modals/upload-modal/UploadModal';
import styles from './Editor.module.scss';
@@ -28,6 +29,7 @@ export default function Editor() {
} = useDisclosure();
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure();
// Set window title
useEffect(() => {
@@ -42,6 +44,7 @@ export default function Editor() {
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
<SheetsModal onClose={onSheetsClose} isOpen={isSheetsOpen} />
</ErrorBoundary>
<div className={styles.mainContainer} data-testid='event-editor'>
<div id='settings' className={styles.settings}>
@@ -58,6 +61,8 @@ export default function Editor() {
onAboutOpen={onAboutModalOpen}
isQuickStartOpen={isQuickStartOpen}
onQuickStartOpen={onQuickStartOpen}
isSheetsOpen={isSheetsOpen}
onSheetsOpen={onSheetsOpen}
/>
</ErrorBoundary>
</div>
@@ -73,8 +73,8 @@ export default function EventEditor() {
handleSubmit={handleSubmit}
>
<CopyTag label='Event ID'>{event.id}</CopyTag>
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid/${event.id}`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue/${event.cue}`}</CopyTag>
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid "${event.id}"`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue "${event.cue}"`}</CopyTag>
</EventEditorDataRight>
</div>
);
@@ -2,16 +2,15 @@ import { useCallback } from 'react';
import { Textarea } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { TitleActions } from './EventEditorDataLeft';
import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss';
interface CountedTextAreaProps {
field: TitleActions;
field: EditorUpdateFields;
label: string;
initialValue: string;
submitHandler: (field: TitleActions, value: string) => void;
submitHandler: (field: EditorUpdateFields, value: string) => void;
}
export default function CountedTextArea(props: CountedTextAreaProps) {
@@ -40,6 +40,7 @@ export default function CountedTextInput(props: CountedTextInputProps) {
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
autoComplete='off'
/>
</div>
);
@@ -1,4 +1,4 @@
import { memo, useState } from 'react';
import { memo } from 'react';
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { calculateDuration, millisToString } from 'ontime-utils';
@@ -7,7 +7,6 @@ import TimeInput from '../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import { cx } from '../../../common/utils/styleUtils';
import { TimeEntryField, validateEntry } from '../../../common/utils/timesManager';
import style from '../EventEditor.module.scss';
@@ -29,13 +28,6 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
const { eventId, timeStart, timeEnd, duration, delay, isPublic, endAction, timerType } = props;
const { updateEvent } = useEventAction();
const [warning, setWarnings] = useState({ start: '', end: '', duration: '' });
const timerValidationHandler = (entry: TimeEntryField, val: number) => {
const valid = validateEntry(entry, val, timeStart, timeEnd);
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
return valid.value;
};
const handleSubmit = (field: TimeActions, value: number | string | boolean) => {
const newEventData: Partial<OntimeEvent> = { id: eventId };
@@ -87,11 +79,9 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
id='timeStart'
name='timeStart'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={timeStart}
delay={delay}
placeholder='Start'
warning={warning.start}
/>
<label className={inputTimeLabels} htmlFor='timeEnd'>
{endLabel}
@@ -100,11 +90,9 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
id='timeEnd'
name='timeEnd'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={timeEnd}
delay={delay}
placeholder='End'
warning={warning.end}
/>
<label className={style.inputLabel} htmlFor='durationOverride'>
Duration
@@ -113,10 +101,8 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
id='durationOverride'
name='durationOverride'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={duration}
placeholder='Duration'
warning={warning.duration}
/>
</div>
<div className={style.timeSettings}>
@@ -5,6 +5,7 @@
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: $inner-section-text-size;
.title {
white-space: nowrap;
@@ -14,12 +15,12 @@
.selected {
min-width: max-content;
font-size: $inner-section-text-size;
color: $label-gray;
}
}
.description {
font-size: $inner-section-text-size;
color: $label-gray;
white-space: nowrap;
overflow: hidden;
+16
View File
@@ -1,5 +1,7 @@
import { memo, useCallback, useEffect, useState } from 'react';
import { VStack } from '@chakra-ui/react';
import { IoCloud } from '@react-icons/all-files/io5/IoCloud';
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
@@ -31,6 +33,8 @@ interface MenuBarProps {
onAboutOpen: () => void;
isQuickStartOpen: boolean;
onQuickStartOpen: () => void;
isSheetsOpen: boolean;
onSheetsOpen: () => void;
}
const buttonStyle = {
@@ -58,6 +62,8 @@ const MenuBar = (props: MenuBarProps) => {
onAboutOpen,
isQuickStartOpen,
onQuickStartOpen,
isSheetsOpen,
onSheetsOpen,
} = props;
const { isElectron, sendToElectron } = useElectronEvent();
@@ -174,6 +180,16 @@ const MenuBar = (props: MenuBarProps) => {
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
isDisabled={appMode === AppMode.Run}
icon={isSheetsOpen ? <IoCloud /> : <IoCloudOutline />}
className={isSheetsOpen ? style.open : ''}
clickHandler={onSheetsOpen}
tooltip='Sheets'
aria-label='Sheets'
size='sm'
/>
<TooltipActionBtn
{...buttonStyle}
isDisabled={appMode === AppMode.Run}
@@ -41,8 +41,8 @@ $el-padding-with-compensation: 24px; // 16 + 8
.title {
font-size: $inner-section-text-size;
color: $gray-500;
padding-left: 8px;
margin: 8px 0;
padding-left: 0.5rem;
margin: 0.5rem 0;
text-transform: uppercase;
}
@@ -107,6 +107,16 @@ $el-padding-with-compensation: 24px; // 16 + 8
color: $error-red;
}
.success {
@include subsection;
color: $action-blue;
}
.feedbackSection {
justify-content: flex-start;
}
.buttonSection {
margin-top: $section-spacing;
display: flex;
@@ -117,6 +127,10 @@ $el-padding-with-compensation: 24px; // 16 + 8
flex-grow: 1;
}
.vSpacer {
height: 2rem;
}
.shiftRight {
align-self: flex-end;
}
@@ -135,6 +149,12 @@ $el-padding-with-compensation: 24px; // 16 + 8
grid-template-columns: auto 1fr;
}
.twoEqualColumn {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.padBottom {
padding-bottom: $element-spacing;
}
@@ -1,10 +1,9 @@
.modalHeader {
font-weight: 400;
}
@use '../../../theme/v2Styles' as *;
.modalBody {
display: flex;
.buttonRow {
justify-content: space-between;
margin-top: 1rem;
margin-bottom: 1rem;
margin-top: $section-spacing;
display: flex;
gap: $section-spacing;
margin: 0 0.25rem;
}
@@ -13,16 +13,16 @@ export default function ExportModal(props: ExportModalProps) {
const { isOpen, onClose } = props;
return (
<Modal isOpen={isOpen} onClose={onClose} motionPreset='scale' size='xl' colorScheme='blackAlpha'>
<Modal isOpen={isOpen} onClose={onClose} motionPreset='slideInBottom' size='xl' variant='ontime-small'>
<ModalOverlay />
<ModalContent>
<ModalHeader className={styles.modalHeader}>Download options</ModalHeader>
<ModalCloseButton />
<ModalBody className={styles.modalBody}>
<Button onClick={() => onClose('csv')} variant='ontime-subtle-on-light' width='48%'>
<ModalBody className={styles.buttonRow}>
<Button onClick={() => onClose('csv')} variant='ontime-subtle-on-light' width='100%'>
Rundown as CSV
</Button>
<Button onClick={() => onClose('json')} variant='ontime-filled' width='48%'>
<Button onClick={() => onClose('json')} variant='ontime-filled' width='100%'>
Project file
</Button>
</ModalBody>
@@ -2,8 +2,9 @@ import { ModalBody, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/r
import ModalWrapper from '../ModalWrapper';
import OscIntegration from './OscIntegration';
import OscSettings from './OscSettings';
import HttpIntegration from './http/HttpIntegration';
import OscIntegration from './osc/OscIntegration';
import OscSettings from './osc/OscSettings';
import styles from '../Modal.module.scss';
@@ -30,6 +31,7 @@ export default function IntegrationModal(props: IntegrationModalProps) {
<TabList>
<Tab>OSC</Tab>
<Tab>OSC Integration</Tab>
<Tab>HTTP Integration</Tab>
</TabList>
<TabPanels>
<TabPanel>
@@ -38,6 +40,9 @@ export default function IntegrationModal(props: IntegrationModalProps) {
<TabPanel>
<OscIntegration />
</TabPanel>
<TabPanel>
<HttpIntegration />
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
@@ -0,0 +1,152 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Switch } from '@chakra-ui/react';
import type { HttpSettings } from 'ontime-types';
import { TimerLifeCycle } from 'ontime-types';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { useEmitLog } from '../../../../common/stores/logger';
import ModalLoader from '../../modal-loader/ModalLoader';
import OntimeModalFooter from '../../OntimeModalFooter';
import { OntimeCycle, sectionText } from '../integration.utils';
import HttpSubscriptionRow from './HttpSubscriptionRow';
import styles from '../../Modal.module.scss';
export default function HttpIntegration() {
const { data, isFetching } = useHttpSettings();
const { mutateAsync } = usePostHttpSettings();
const { emitError } = useEmitLog();
const {
control,
handleSubmit,
register,
reset,
formState: { isSubmitting, isDirty, isValid },
} = useForm<HttpSettings>({
mode: 'onBlur',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const [showSection, setShowSection] = useState<OntimeCycle>(TimerLifeCycle.onLoad);
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const resetForm = () => {
reset(data);
};
const onSubmit = async (values: HttpSettings) => {
try {
const newSettings: HttpSettings = {
enabledOut: Boolean(values.enabledOut),
subscriptions: {
onLoad: values.subscriptions.onLoad ?? [],
onStart: values.subscriptions.onStart ?? [],
onPause: values.subscriptions.onPause ?? [],
onStop: values.subscriptions.onStop ?? [],
onUpdate: values.subscriptions.onUpdate ?? [],
onFinish: values.subscriptions.onFinish ?? [],
},
};
await mutateAsync(newSettings);
} catch (error) {
emitError(`Error setting HTML: ${error}`);
}
};
if (isFetching) {
return <ModalLoader />;
}
const placeholder = 'http://x.x.x.x:xxxx/api/path';
return (
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='http-subscriptions'>
<div className={styles.splitSection}>
<div>
<span className={`${styles.sectionTitle} ${styles.main}`}>HTTP Output</span>
<span className={styles.sectionSubtitle}>Ontime data feedback</span>
</div>
<Switch {...register('enabledOut')} variant='ontime-on-light' />
</div>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onLoad}
title={sectionText.onLoad.title}
subtitle={sectionText.onLoad.subtitle}
visible={showSection === TimerLifeCycle.onLoad}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onStart}
title={sectionText.onStart.title}
subtitle={sectionText.onStart.subtitle}
visible={showSection === TimerLifeCycle.onStart}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onPause}
title={sectionText.onPause.title}
subtitle={sectionText.onPause.subtitle}
visible={showSection === TimerLifeCycle.onPause}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onStop}
title={sectionText.onStop.title}
subtitle={sectionText.onStop.subtitle}
visible={showSection === TimerLifeCycle.onStop}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onUpdate}
title={sectionText.onUpdate.title}
subtitle={sectionText.onUpdate.subtitle}
visible={showSection === TimerLifeCycle.onUpdate}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onFinish}
title={sectionText.onFinish.title}
subtitle={sectionText.onFinish.subtitle}
visible={showSection === TimerLifeCycle.onFinish}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OntimeModalFooter
formId='http-subscriptions'
handleRevert={resetForm}
isDirty={isDirty}
isValid={isValid}
isSubmitting={isSubmitting}
/>
</form>
);
}
@@ -0,0 +1,95 @@
import { Control, useFieldArray, UseFormRegister } from 'react-hook-form';
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { HttpSettings, TimerLifeCycle } from 'ontime-types';
import { useEmitLog } from '../../../../common/stores/logger';
import { startsWithHttp } from '../../../../common/utils/regex';
import collapseStyles from '../../../../common/components/collapse-bar/CollapseBar.module.scss';
import styles from '../../Modal.module.scss';
interface SubscriptionRowProps {
cycle: TimerLifeCycle;
title: string;
subtitle: string;
visible: boolean;
setShowSection: (cycle: TimerLifeCycle) => void;
register: UseFormRegister<HttpSettings>;
control: Control<HttpSettings>;
placeholder: string;
}
export default function SubscriptionRow(props: SubscriptionRowProps) {
const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props;
const { emitError } = useEmitLog();
const { fields, append, remove } = useFieldArray({
name: `subscriptions.${cycle}`,
control,
});
const hasTooManyOptions = fields.length >= 3;
const headerStyle = `${styles.splitSection} ${visible ? '' : styles.showPointer}`;
const sectionTitle = `${title} ${fields.length ? fields.length : '-'} / 3`;
const handleAddNew = () => {
if (hasTooManyOptions) {
emitError(`Maximum amount of ${cycle} subscriptions reached (3)`);
return;
}
append({
message: '',
enabled: false,
});
};
return (
<>
<div className={headerStyle} onClick={() => setShowSection(cycle)}>
<div>
<span className={`${styles.sectionTitle} ${styles.main}`}>{sectionTitle}</span>
{visible && <span className={styles.sectionSubtitle}>{subtitle}</span>}
</div>
<FiChevronUp className={visible ? collapseStyles.moreCollapsed : collapseStyles.moreExpanded} />
</div>
{visible && (
<>
{fields.map((subscription, index) => (
<div key={subscription.id} className={styles.entryRow}>
<IconButton
icon={<IoRemove />}
onClick={() => remove(index)}
aria-label='delete'
size='xs'
colorScheme='red'
/>
<Input
placeholder={placeholder}
size='xs'
variant='ontime-filled-on-light'
autoComplete='off'
{...register(`subscriptions.${cycle}.${index}.message`, {
pattern: { value: startsWithHttp, message: 'Request address must start with http://' },
})}
/>
<Switch variant='ontime-on-light' {...register(`subscriptions.${cycle}.${index}.enabled`)} />
</div>
))}
<Button
onClick={handleAddNew}
className={styles.shiftRight}
isDisabled={hasTooManyOptions}
size='xs'
colorScheme='blue'
variant='outline'
padding='0 2em'
>
Add new
</Button>
</>
)}
</>
);
}
@@ -0,0 +1,30 @@
import { TimerLifeCycle } from 'ontime-types';
export type OntimeCycle = keyof typeof TimerLifeCycle;
export const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = {
onLoad: {
title: 'On Load',
subtitle: 'Triggers when a timer is loaded',
},
onStart: {
title: 'On Start',
subtitle: 'Triggers when a timer starts',
},
onPause: {
title: 'On Pause',
subtitle: 'Triggers when a running timer is paused',
},
onStop: {
title: 'On Stop',
subtitle: 'Triggers when a running timer is stopped',
},
onUpdate: {
title: 'On Every Second',
subtitle: 'Triggers when a running timer is updated (at least once a second, can be more)',
},
onFinish: {
title: 'On Finish',
subtitle: 'Triggers when a running reaches 0',
},
};
@@ -3,43 +3,15 @@ import { useForm } from 'react-hook-form';
import type { OscSubscription } from 'ontime-types';
import { TimerLifeCycle } from 'ontime-types';
import useOscSettings, { usePostOscSubscriptions } from '../../../common/hooks-query/useOscSettings';
import { useEmitLog } from '../../../common/stores/logger';
import ModalLoader from '../modal-loader/ModalLoader';
import OntimeModalFooter from '../OntimeModalFooter';
import useOscSettings, { usePostOscSubscriptions } from '../../../../common/hooks-query/useOscSettings';
import { useEmitLog } from '../../../../common/stores/logger';
import ModalLoader from '../../modal-loader/ModalLoader';
import OntimeModalFooter from '../../OntimeModalFooter';
import { type OntimeCycle, sectionText } from '../integration.utils';
import OscSubscriptionRow from './OscSubscriptionRow';
import styles from '../Modal.module.scss';
type OntimeCycle = keyof typeof TimerLifeCycle;
const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = {
onLoad: {
title: 'On Load',
subtitle: 'Triggers when a timer is loaded',
},
onStart: {
title: 'On Start',
subtitle: 'Triggers when a timer starts',
},
onPause: {
title: 'On Pause',
subtitle: 'Triggers when a running timer is paused',
},
onStop: {
title: 'On Stop',
subtitle: 'Triggers when a running timer is stopped',
},
onUpdate: {
title: 'On Every Second',
subtitle: 'Triggers when a running timer is updated (at least once a second, can be more)',
},
onFinish: {
title: 'On Finish',
subtitle: 'Triggers when a running reaches 0',
},
};
import styles from '../../Modal.module.scss';
export default function OscIntegration() {
const { data, isFetching } = useOscSettings();
@@ -92,6 +64,7 @@ export default function OscIntegration() {
return <ModalLoader />;
}
const placeholder = 'OSC message';
return (
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='osc-subscriptions'>
<OscSubscriptionRow
@@ -102,6 +75,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onStart}
@@ -111,6 +85,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onPause}
@@ -120,6 +95,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onStop}
@@ -129,6 +105,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onUpdate}
@@ -138,6 +115,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onFinish}
@@ -147,6 +125,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OntimeModalFooter
formId='osc-subscriptions'
@@ -2,14 +2,14 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { FormControl, Input, Switch } from '@chakra-ui/react';
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
import { PlaceholderSettings } from '../../../common/models/OscSettings';
import { useEmitLog } from '../../../common/stores/logger';
import { isIPAddress, isOnlyNumbers } from '../../../common/utils/regex';
import ModalLoader from '../modal-loader/ModalLoader';
import OntimeModalFooter from '../OntimeModalFooter';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { PlaceholderSettings } from '../../../../common/models/OscSettings';
import { useEmitLog } from '../../../../common/stores/logger';
import { isIPAddress, isOnlyNumbers } from '../../../../common/utils/regex';
import ModalLoader from '../../modal-loader/ModalLoader';
import OntimeModalFooter from '../../OntimeModalFooter';
import styles from '../Modal.module.scss';
import styles from '../../Modal.module.scss';
export default function OscSettings() {
const { data, isFetching } = useOscSettings();
@@ -57,7 +57,6 @@ export default function OscSettings() {
};
const resetForm = () => {
// @ts-expect-error -- we know the types dont match
reset(data);
};
@@ -2,12 +2,12 @@ import { Control, useFieldArray, UseFormRegister } from 'react-hook-form';
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { OscSubscription, TimerLifeCycle } from 'ontime-types';
import type { OscSubscription, TimerLifeCycle } from 'ontime-types';
import { useEmitLog } from '../../../common/stores/logger';
import { useEmitLog } from '../../../../common/stores/logger';
import collapseStyles from '../../../common/components/collapse-bar/CollapseBar.module.scss';
import styles from '../Modal.module.scss';
import collapseStyles from '../../../../common/components/collapse-bar/CollapseBar.module.scss';
import styles from '../../Modal.module.scss';
interface OscSubscriptionRowProps {
cycle: TimerLifeCycle;
@@ -17,10 +17,11 @@ interface OscSubscriptionRowProps {
setShowSection: (cycle: TimerLifeCycle) => void;
register: UseFormRegister<OscSubscription>;
control: Control<OscSubscription>;
placeholder: string;
}
export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
const { cycle, title, subtitle, visible, setShowSection, register, control } = props;
const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props;
const { emitError } = useEmitLog();
const { fields, append, remove } = useFieldArray({
name: cycle,
@@ -64,7 +65,7 @@ export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
colorScheme='red'
/>
<Input
placeholder='OSC Message'
placeholder={placeholder}
size='xs'
variant='ontime-filled-on-light'
autoComplete='off'
@@ -18,7 +18,7 @@ import {
} from '@chakra-ui/react';
import type { ProjectData } from 'ontime-types';
import { PROJECT_DATA, RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { PROJECT_DATA, RUNDOWN } from '../../../common/api/apiConstants';
import { postNew } from '../../../common/api/ontimeApi';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
@@ -52,8 +52,8 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
const onSubmit = async (data: Partial<ProjectData>) => {
try {
await postNew(data);
await ontimeQueryClient.invalidateQueries(PROJECT_DATA);
await ontimeQueryClient.invalidateQueries(RUNDOWN_TABLE);
await ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_DATA });
await ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
onClose();
} catch (_) {
@@ -21,7 +21,9 @@ import style from './SettingsModal.module.scss';
const aliasesDocsUrl = 'https://ontime.gitbook.io/v2/features/url-aliases';
// we wrap the array in an object to be simplify react-hook-form
type Aliases = { aliases: Alias[] };
type Aliases = {
aliases: Alias[];
};
export default function AliasesForm() {
const { data, status, isFetching, refetch } = useAliases();
@@ -46,7 +48,7 @@ export default function AliasesForm() {
useEffect(() => {
if (data) {
reset(data);
reset({ aliases: data });
}
}, [data, reset]);
@@ -76,7 +78,7 @@ export default function AliasesForm() {
});
};
const disableInputs = status === 'loading';
const disableInputs = status === 'pending';
const hasTooManyOptions = fields.length >= 20;
if (isFetching) {
@@ -93,8 +95,7 @@ export default function AliasesForm() {
<AlertDescription>
Custom aliases allow providing a short name for any ontime URL. <br />
It serves two primary purposes: <br />
- Providing dynamic URLs for automation or unattended screens <br />
- Simplifying complex URLs
- Providing dynamic URLs for automation or unattended screens <br />- Simplifying complex URLs
<ModalLink href={aliasesDocsUrl}>For more information, see the docs</ModalLink>
</AlertDescription>
</div>
@@ -111,6 +112,7 @@ export default function AliasesForm() {
icon={<IoRemove />}
colorScheme='red'
isDisabled={disableInputs}
data-testid={`field__delete_${index}`}
/>
<Input
{...inputProps}
@@ -120,6 +122,7 @@ export default function AliasesForm() {
variant='ontime-filled-on-light'
placeholder='URL Alias'
isDisabled={disableInputs}
data-testid={`field__alias_${index}`}
/>
<Input
{...inputProps}
@@ -129,6 +132,7 @@ export default function AliasesForm() {
variant='ontime-filled-on-light'
placeholder='URL (portion after ontime Port)'
isDisabled={disableInputs}
data-testid={`field__url_${index}`}
/>
<TooltipActionBtn
clickHandler={(event) => handleLinks(event, alias.alias)}
@@ -139,8 +143,14 @@ export default function AliasesForm() {
icon={<IoOpenOutline />}
colorScheme='red'
isDisabled={disableInputs}
data-testid={`field__test_${index}`}
/>
<Switch
{...register(`aliases.${index}.enabled`)}
variant='ontime-on-light'
isDisabled={disableInputs}
data-testid={`field__enable_${index}`}
/>
<Switch {...register(`aliases.${index}.enabled`)} variant='ontime-on-light' isDisabled={disableInputs} />
</li>
);
})}
@@ -50,7 +50,7 @@ export default function AppSettingsModal() {
reset(data);
};
const disableInputs = status === 'loading';
const disableInputs = status === 'pending';
if (isFetching) {
return <ModalLoader />;
@@ -87,7 +87,7 @@ export default function AppSettingsModal() {
description='Protect the editor with a pin code'
error={errors.editorKey?.message}
>
<ModalPinInput register={register} formName='editorKey' isDisabled={disableInputs} />
<ModalPinInput register={register as any} formName='editorKey' isDisabled={disableInputs} />
</ModalSplitInput>
<ModalSplitInput
field='operatorKey'
@@ -95,7 +95,7 @@ export default function AppSettingsModal() {
description='Protect the cuesheet with a pin code'
error={errors.operatorKey?.message}
>
<ModalPinInput register={register} formName='operatorKey' isDisabled={disableInputs} />
<ModalPinInput register={register as any} formName='operatorKey' isDisabled={disableInputs} />
</ModalSplitInput>
<div style={{ height: '16px' }} />
<ModalSplitInput
@@ -119,6 +119,7 @@ export default function AppSettingsModal() {
<option value='en'>English</option>
<option value='fr'>French</option>
<option value='de'>German</option>
<option value='it'>Italian</option>
<option value='no'>Norwegian</option>
<option value='pt'>Portuguese</option>
<option value='es'>Spanish</option>
@@ -51,7 +51,7 @@ export default function CuesheetSettingsForm() {
reset(data);
};
const disableInputs = status === 'loading';
const disableInputs = status === 'pending';
if (isFetching) {
return <ModalLoader />;
@@ -3,8 +3,12 @@ import { UseFormRegister } from 'react-hook-form';
import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
interface FormInput {
[key: string]: string;
}
interface ModalPinInputProps {
register: UseFormRegister<any>;
register: UseFormRegister<FormInput>;
formName: string;
isDisabled?: boolean;
}
@@ -48,7 +48,7 @@ export default function ProjectDataForm() {
reset(data);
};
const disableInputs = status === 'loading';
const disableInputs = status === 'pending';
if (isFetching) {
return <ModalLoader />;
@@ -3,19 +3,24 @@
.aliases {
display: flex;
align-items: center;
gap: 8px;
gap: 0.5rem;
flex-direction: column;
width: 100%;
padding: 8px 0;
padding: 0.5rem 0;
.aliasRow {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
gap: 0.5rem;
}
.grow {
flex: 1;
}
}
.url {
font-size: calc(1rem - 2px);
user-select: text;
}
@@ -1,16 +1,18 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Input, Switch } from '@chakra-ui/react';
import { Alert, AlertDescription, AlertIcon, AlertTitle, Input, Switch } from '@chakra-ui/react';
import { ViewSettings } from 'ontime-types';
import { logAxiosError } from '../../../common/api/apiUtils';
import { postViewSettings } from '../../../common/api/ontimeApi';
import { PopoverPickerRHF } from '../../../common/components/input/popover-picker/PopoverPicker';
import useInfo from '../../../common/hooks-query/useInfo';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { mtm } from '../../../common/utils/timeConstants';
import ModalLoader from '../modal-loader/ModalLoader';
import { inputProps } from '../modalHelper';
import ModalInput from '../ModalInput';
import ModalLink from '../ModalLink';
import ModalSplitInput from '../ModalSplitInput';
import OntimeModalFooter from '../OntimeModalFooter';
@@ -18,8 +20,12 @@ import InputMillisWithString from './InputMillisWithString';
import style from './SettingsModal.module.scss';
const cssOverrideDocsUrl = 'https://ontime.gitbook.io/v2/features/custom-styling';
export default function ViewSettingsForm() {
const { data, status, refetch, isFetching } = useViewSettings();
const { data: info, isFetching: isFetchingInfo } = useInfo();
const {
control,
handleSubmit,
@@ -73,15 +79,26 @@ export default function ViewSettingsForm() {
return null;
}
const disableInputs = status === 'loading';
const disableInputs = status === 'pending';
if (isFetching) {
if (isFetching || isFetchingInfo) {
return <ModalLoader />;
}
return (
<form onSubmit={handleSubmit(onSubmit)} id='view-settings' className={style.sectionContainer}>
<span className={style.title}>General view settings</span>
<Alert status='info' variant='ontime-on-light-info'>
<AlertIcon />
<div className={style.column}>
<AlertTitle>CSS Override</AlertTitle>
<AlertDescription>
Ontime will use the CSS file at its install location. <br />
<span className={style.url}>{info?.cssOverride}</span>
<ModalLink href={cssOverrideDocsUrl}>For more information, see the docs</ModalLink>
</AlertDescription>
</div>
</Alert>
<ModalSplitInput
field='overrideStyles'
title='Override CSS Styles'
@@ -0,0 +1,469 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Button,
HStack,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
} from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
import { PROJECT_DATA, RUNDOWN, USERFIELDS } from '../../../common/api/apiConstants';
import { maybeAxiosError } from '../../../common/api/apiUtils';
import {
getAuthentication,
getClientSecrect,
getSheetsAuthUrl,
patchData,
postId,
postPreviewSheet,
postPushSheet,
postWorksheet,
uploadSheetClientFile,
} from '../../../common/api/ontimeApi';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
import { openLink } from '../../../common/utils/linkUtils';
import ModalLink from '../ModalLink';
import PreviewExcel from '../upload-modal/preview/PreviewExcel';
import ExcelFileOptions from '../upload-modal/upload-options/ExcelFileOptions';
import Step from './Step';
interface SheetsModalProps {
onClose: () => void;
isOpen: boolean;
}
export default function SheetsModal(props: SheetsModalProps) {
const { isOpen, onClose } = props;
const queryClient = useQueryClient();
const [rundown, setRundown] = useState<OntimeRundown | null>(null);
const [userFields, setUserFields] = useState<UserFields | null>(null);
const [project, setProject] = useState<ProjectData | null>(null);
const [id, setSheetId] = useState('');
const [worksheet, setWorksheet] = useState('');
const [worksheetOptions, setWorksheetOptions] = useState<string[]>([]);
const [direction, setDirection] = useState('none');
const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
const fileInputRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState({
clientSecret: { complete: false, message: '' },
authenticate: { complete: false, message: '' },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
useEffect(() => {
if (isOpen) {
setDirection('none');
testClientSecret();
if (state.clientSecret.complete) testAuthentication();
if (state.authenticate.complete) testSheetId();
}
}, [isOpen]);
const handleClose = () => {
setRundown(null);
setProject(null);
setUserFields(null);
onClose();
};
//SETP-1 Upload Client ID
const handleClick = () => {
fileInputRef.current?.click();
};
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.files?.length) {
setState({
clientSecret: { complete: false, message: 'Missing file' },
authenticate: { complete: false, message: '' },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
return;
}
const selectedFile = event.target.files[0];
uploadSheetClientFile(selectedFile)
.then(() => {
setState({ ...state, clientSecret: { complete: true, message: '' } });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
clientSecret: { complete: false, message },
authenticate: { complete: false, message: '' },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
});
};
const testClientSecret = () => {
getClientSecrect()
.then(() => {
setState({ ...state, clientSecret: { complete: true, message: '' } });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
clientSecret: { complete: false, message },
authenticate: { complete: false, message: '' },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
});
};
//SETP-2 Authenticate
const handleAuthenticate = () => {
getSheetsAuthUrl()
.then((data) => {
openLink(data);
window.addEventListener('focus', () => testAuthentication(), { once: true });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
...state,
authenticate: { complete: false, message },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
});
};
const testAuthentication = () => {
getAuthentication()
.then(() => {
setState({ ...state, authenticate: { complete: true, message: '' } });
})
.catch((error) => {
const message = maybeAxiosError(error);
setState({
...state,
authenticate: { complete: false, message },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
});
};
//SETP-3 set sheet ID
const testSheetId = () => {
postId(id)
.then((data) => {
setState({ ...state, id: { complete: true, message: '' } });
setWorksheetOptions(data.worksheetOptions);
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
...state,
id: { complete: false, message },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
setWorksheetOptions([]);
});
};
//SETP-4 Select Worksheet
const testWorksheet = (value: string) => {
excelFileOptions.current.worksheet = value;
setWorksheet(value);
postWorksheet(id, worksheet)
.then(() => {
setState({ ...state, worksheet: { complete: true, message: '' } });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({ ...state, worksheet: { complete: false, message }, pullPush: { complete: false, message: '' } });
});
};
//SETP-5 Upload / Download
const updateExcelFileOptions = <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => {
if (excelFileOptions.current[field] !== value) {
excelFileOptions.current = { ...excelFileOptions.current, [field]: value };
}
};
const handlePullData = () => {
postPreviewSheet(id, excelFileOptions.current)
.then((data) => {
setProject(data.project);
setRundown(data.rundown);
setUserFields(data.userFields);
})
.catch((error) => {
const message = maybeAxiosError(error);
setDirection('none');
setState({ ...state, pullPush: { complete: false, message } });
});
};
const handlePushData = () => {
postPushSheet(id, excelFileOptions.current)
.then(() => {
setDirection('none');
setState({ ...state, pullPush: { complete: true, message: '' } });
})
.catch((error) => {
const message = maybeAxiosError(error);
setDirection('none');
setState({ ...state, pullPush: { complete: false, message } });
});
};
//GET preview
const handleFinalise = async () => {
if (rundown && userFields && project) {
let doClose = false;
try {
await patchData({ rundown, userFields, project });
queryClient.setQueryData(RUNDOWN, rundown);
queryClient.setQueryData(USERFIELDS, userFields);
queryClient.setQueryData(PROJECT_DATA, project);
await queryClient.invalidateQueries({
queryKey: [...RUNDOWN, ...USERFIELDS, ...PROJECT_DATA],
});
doClose = true;
} catch (error) {
const message = maybeAxiosError(error);
console.error(message);
} finally {
if (doClose) {
handleClose();
}
}
}
};
return (
<Modal
onClose={handleClose}
isOpen={isOpen}
closeOnOverlayClick={false}
motionPreset='slideInBottom'
size='xl'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-upload'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rundown from sheets (experimental)</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Alert status='info' variant='ontime-on-light-info'>
<AlertIcon />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<AlertTitle>Sync with Google Sheets</AlertTitle>
<AlertDescription>
<ModalLink href='https://ontime.gitbook.io/v2/features/google-sheet'>
For more information, see the docs
</ModalLink>
</AlertDescription>
</div>
</Alert>
{!rundown ? (
direction === 'up' || direction === 'down' ? (
<ExcelFileOptions optionsRef={excelFileOptions} updateOptions={updateExcelFileOptions} />
) : (
<>
<Step
title='1 - Upload OAuth 2.0 Client ID'
completed={state.clientSecret.complete}
disabled={false}
error={state.clientSecret.message}
>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json'
data-testid='file-input'
/>
<div style={{ display: 'flex', gap: '1em' }}>
<Button size='sm' variant='ontime-subtle-on-light' onClick={handleClick}>
{state.clientSecret.complete ? 'Reupload Client ID' : 'Upload Client ID'}
</Button>
<Button size='sm' variant='ontime-ghosted-on-light' onClick={testClientSecret}>
Retry Client ID
</Button>
</div>
</Step>
<Step
title='2 - Authenticate with Google'
completed={state.authenticate.complete}
disabled={!state.clientSecret.complete}
error={state.authenticate.message}
>
<div style={{ display: 'flex', gap: '1em' }}>
<Button
size='sm'
variant='ontime-subtle-on-light'
onClick={handleAuthenticate}
isDisabled={!state.clientSecret.complete}
>
Authenticate
</Button>
<Button
size='sm'
variant='ontime-ghosted-on-light'
onClick={testAuthentication}
isDisabled={!state.clientSecret.complete}
>
Retry Connection
</Button>
</div>
</Step>
<Step
title='3 - Add Document ID'
completed={state.id.complete}
disabled={!state.authenticate.complete}
error={state.id.message}
>
<HStack>
<Input
type='text'
size='sm'
variant='ontime-filled-on-light'
isDisabled={!state.authenticate.complete}
value={id}
onChange={(event) => setSheetId(event.target.value)}
/>
<Button
isDisabled={!state.authenticate.complete}
size='sm'
variant='ontime-subtle-on-light'
padding='0 2em'
onClick={testSheetId}
>
Connect
</Button>
</HStack>
</Step>
<Step
title='4 - Select Worksheet to import'
completed={state.worksheet.complete}
disabled={worksheetOptions.length == 0}
>
<Select
size='sm'
isDisabled={worksheetOptions.length == 0}
placeholder='Select a worksheet'
onChange={(event) => testWorksheet(event.target.value)}
value={worksheet}
>
{worksheetOptions.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</Select>
</Step>
<Step title='5 - Upload / Download rundown' completed={false} disabled={!state.worksheet.complete}>
<div style={{ display: 'flex', gap: '1em' }}>
<Button
isDisabled={!state.worksheet.complete}
variant='ontime-subtle-on-light'
padding='0 2em'
onClick={() => setDirection('up')}
>
Upload
</Button>
<Button
isDisabled={!state.worksheet.complete}
variant='ontime-subtle-on-light'
padding='0 2em'
onClick={() => setDirection('down')}
>
Download
</Button>
</div>
</Step>
</>
)
) : (
<PreviewExcel
rundown={rundown ?? []}
project={project ?? projectDataPlaceholder}
userFields={userFields ?? userFieldsPlaceholder}
/>
)}
</ModalBody>
<ModalFooter>
{rundown ? (
<div style={{ display: 'flex', gap: '1em' }}>
<Button
onClick={() => {
setRundown(null);
setDirection('none');
}}
variant='ontime-ghost-on-light'
>
Go Back
</Button>
<Button variant='ontime-filled' padding='0 2em' onClick={handleFinalise}>
Import
</Button>
</div>
) : direction === 'up' ? (
<div style={{ display: 'flex', gap: '1em' }}>
<Button onClick={() => setDirection('none')} variant='ontime-ghost-on-light'>
Go Back
</Button>
<Button variant='ontime-filled' padding='0 2em' onClick={handlePushData}>
Upload
</Button>
</div>
) : direction === 'down' ? (
<div style={{ display: 'flex', gap: '1em' }}>
<Button onClick={() => setDirection('none')} variant='ontime-ghost-on-light'>
Go Back
</Button>
<Button variant='ontime-filled' padding='0 2em' onClick={handlePullData}>
Preview
</Button>
</div>
) : (
<></>
)}
</ModalFooter>
</ModalContent>
</Modal>
);
}

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