Compare commits

...

84 Commits

Author SHA1 Message Date
Carlos Valente 1a95bc43a3 folow selected 2025-06-17 06:31:46 +02:00
Carlos Valente 1604762f2b style tweaks 2025-06-17 06:31:46 +02:00
Carlos Valente 2f8f4ad129 wip: countdown view redesign 2025-06-17 06:31:46 +02:00
Carlos Valente 0c7c90cae4 refactor: update flat rundown 2025-06-17 06:31:46 +02:00
Carlos Valente 1148ac1924 refactor: remove public view 2025-06-17 06:31:46 +02:00
Carlos Valente 100fbabfc6 refactor: inactivity checks keyboard and has initial state 2025-06-17 06:31:46 +02:00
Carlos Valente 42810a97ca refactor: small behaviour tweaks 2025-06-17 06:31:46 +02:00
Carlos Valente 05a9b2af1a refactor: action-blue is from blue scale 2025-06-17 06:31:46 +02:00
Carlos Valente 1bac3a7fd0 refactor: style tweaks to view styles 2025-06-17 06:31:46 +02:00
Carlos Valente 8281314ede refactor: use ontime button in view 2025-06-17 06:31:46 +02:00
Carlos Valente 3e0b3f100e refactor: create reusable buttons 2025-06-17 06:31:46 +02:00
google-labs-jules[bot] fb93063b47 refactor: style tweaks to edit css modal 2025-06-13 10:36:48 +02:00
google-labs-jules[bot] 24d3bf05ca refactor: remove usages of framer-motion 2025-06-13 10:36:26 +02:00
Alex Christoffer Rasmussen a4b15970de Upgrade expressjs (#1633)
* upgrade expressjs

* migration

* reenable test

* extend timeout on download test

* fixup! migration

* move empty body test from controller to validator

* enusre not empty

* extract validation function

* fixup! reenable test

* remove thin controllers

* disable e2e test of project file download
2025-06-12 15:02:35 +02:00
Alex Christoffer Rasmussen 2a5b053d97 Refactor: require trigger in all events objects (#1636)
* reqire trigger in all events

* use structuredClone when cloning an event
2025-06-12 09:54:57 +02:00
google-labs-jules[bot] 02c30c9681 Fix: Correct boundary condition in applyDelay
The `applyDelay` function had a condition that incorrectly used `rundown.order.length` instead of `rundownMetadata.flatEntryOrder.length` to check if a delay entry was the last in the sequence. `rundown.order` only contains top-level entries, while `flatEntryOrder` contains all entries, including those within blocks, which is the relevant list for this check.

This commit corrects the condition to use `rundownMetadata.flatEntryOrder.length`.

Existing tests in `rundown.dao.test.ts` (specifically the test `removes delays in last position without applying changes`) already cover this scenario and pass with the correction, ensuring the fix behaves as expected.
2025-06-10 21:37:40 +02:00
Alex Christoffer Rasmussen 2de7221653 let vite be the proxy to the dev server (#1630) 2025-06-09 14:12:59 +02:00
Carlos Valente 2498e59156 refactor: migrate custom fields to transactions
refactor: extract functions to api domain

refactor: strict custom field parsing

refactor: remove rundown cache utilities

refactor: directory restructure
2025-06-09 14:12:59 +02:00
Carlos Valente f3b4ea0155 refactor: simplify validations 2025-06-09 14:12:58 +02:00
Carlos Valente aebf949883 refactor: create transaction system and apply to adding entry (#1620)
* refactor: create transaction system and apply to adding entry

* refactor: migrate edit mutations to transaction

* refactor: migrate delete mutation to transaction

* refactor: migrate reorder mutation to transaction

* refactor: migrate apply delay to transaction

* refactor: migrate swapEvents to transaction

* refactor: migrate clone to transaction

* refactor: migrate group/ungroup transactions

* refactor: simplify mutations

* refactor: migrate getters

* chore: add tests to processRundown()
2025-06-09 14:12:58 +02:00
Alex Christoffer Rasmussen 33ac05ebee Refactor: better rounding (#1594) 2025-06-09 14:12:58 +02:00
Carlos Valente 421183fe55 refactor: improve reorder logic 2025-06-09 14:12:58 +02:00
Carlos Valente b66c19769d chore: simplify URLs 2025-06-09 14:12:57 +02:00
Carlos Valente 7b8445597f refactor: order is single source of truth 2025-06-09 14:12:57 +02:00
Carlos Valente 0b719c485f refactor: refetch targets is enum 2025-06-09 14:12:57 +02:00
Carlos Valente 17deeddb87 refactor: small ux improvements
- rename dissolve > ungroup
- prevent ondrag when clicking
- add untitled as block title fallback
- move block action to context menu
2025-06-09 14:12:57 +02:00
Carlos Valente 2546159a94 refactor: remove trivially inferred numEvents 2025-06-09 14:12:56 +02:00
Carlos Valente 2f26f20db5 feat: duplicate groups 2025-06-09 14:12:56 +02:00
Carlos Valente 11e0215530 feat: create group from entry selection 2025-06-09 14:12:56 +02:00
Carlos Valente ea54953298 feat: create block from rundown empty 2025-06-09 14:12:56 +02:00
Carlos Valente d21f132674 fix: collapsed blocks dont render children 2025-06-09 14:12:56 +02:00
Carlos Valente c7b2f0e89c refactor: type cleanup and test improvements 2025-06-09 14:12:56 +02:00
Carlos Valente a1505606cf feat: allow dissolving a block 2025-06-09 14:12:56 +02:00
Carlos Valente 9edff55bd5 fix: uncontrolled prop on controlled component 2025-06-09 14:12:56 +02:00
Carlos Valente e287215179 refactor: improve return of reorder 2025-06-09 14:12:56 +02:00
Carlos Valente 776649c997 refactor: mutations on batch elements must have IDs 2025-06-09 14:12:56 +02:00
Carlos Valente 9de5e0ac48 chore: upgrade dependencies 2025-06-09 14:12:55 +02:00
Carlos Valente c7d99073f6 refactor: extract utility to merge two arrays 2025-06-09 14:12:55 +02:00
Carlos Valente 5c1694d802 refactor: change network mode defaults 2025-06-09 14:12:55 +02:00
Carlos Valente 7cd319a6b3 fix: delete nested events 2025-06-09 14:12:55 +02:00
Carlos Valente 2c9b6918e6 fix: add event at end of block 2025-06-09 14:12:55 +02:00
Carlos Valente a50cfad7ef refactor: make finder available in exported rundown 2025-06-09 14:12:55 +02:00
Alex Christoffer Rasmussen 66cd1decde assert non null and update test (#1604) 2025-06-09 14:12:55 +02:00
Alex Christoffer Rasmussen c9bf6d9812 Fix project renumber (#1597)
* fix: generateUniqueFileName

* loadProject should not generate new names

* update comments

* extract and test getProjectNumber

* spell

* finish jsdoc

* use getProjectNumber

* cleanup loadProject

* create a `incrementProjectNumber` function

* spelling

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-06-09 14:12:55 +02:00
Alex Christoffer Rasmussen 8557f64382 Refactor: WebSocket from flush queue to one patch (#1595)
* change flush to one patch

* remove unused types

* create batch

* merge patch into eventStore

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-09 14:12:55 +02:00
Alex Christoffer Rasmussen 8e994fb0df Refactor: ms for api calls (#1593)
* use ms for api calls

* change it in UI

* update comment

* rename variables

* refactor: addtime api logic
2025-06-09 14:12:55 +02:00
Alex Christoffer Rasmussen 5c47a834b5 fix test (#1601)
* fix test

* add comment
2025-06-09 14:12:55 +02:00
Carlos Valente f356afef43 fix: rebase master 2025-06-09 14:12:55 +02:00
Carlos Valente eba5252e40 chore: correct test path 2025-06-09 14:12:54 +02:00
Carlos Valente 72c4ad789a refactor: extract rundown parsing
refactor: implement groups in editor
2025-06-09 14:12:54 +02:00
Carlos Valente 3118c20719 chore: improve convention entry <> event 2025-06-09 14:12:54 +02:00
Carlos Valente 3754c374c3 refactor: maintain flat orders 2025-06-09 14:12:54 +02:00
Carlos Valente dd6d74121c refactor: implement operations on nested events 2025-06-09 14:12:54 +02:00
Carlos Valente 6fb42b989e refactor: process events in rundown 2025-06-09 14:12:54 +02:00
Carlos Valente 82dc1bc56b chore: improve convention entry <> event 2025-06-09 14:12:54 +02:00
Carlos Valente d2c7b34142 chore: rename currentBlock > parent 2025-06-09 14:12:53 +02:00
Carlos Valente 918f8d1b08 refactor: fix delay positioning in gaps 2025-06-09 14:12:53 +02:00
Carlos Valente fcbcf70e01 refactor(e2e): skip flaky test 2025-06-09 14:12:53 +02:00
Carlos Valente 5c6b06a0d4 refactor: improve project loading 2025-06-09 14:12:53 +02:00
Carlos Valente fc4c1bf22e refactor: gather group metadata 2025-06-09 14:12:53 +02:00
Carlos Valente 84fac739f0 refactor: swap maintains schedule 2025-06-09 14:12:53 +02:00
Carlos Valente ef73c87e2f chore: rename files 2025-06-09 14:12:53 +02:00
Carlos Valente 26ee5768a5 refactor: restructure model to contain an object of rundowns 2025-06-09 14:12:53 +02:00
Carlos Valente 27dcf06c73 refactor: clearer relationship on rundown elements 2025-06-09 14:12:52 +02:00
Carlos Valente d48b2ae506 refactor: use strict typing 2025-06-09 14:12:52 +02:00
Carlos Valente 14a7c04d3b refactor: remove stop as a possible end action 2025-06-09 14:12:52 +02:00
Carlos Valente abd9b127db refactor: restructure model to contain an object of rundowns 2025-06-09 14:12:51 +02:00
Carlos Valente 0a80d6db31 chore: remove IDE files 2025-06-09 14:12:51 +02:00
Carlos Valente 977c99e587 refactor: remove unused and legacy code
- remove legacy migrations
- remove unused server code
- remove unused UI code
2025-06-09 14:12:51 +02:00
jwetzell fe8bbd5003 add electron and esbuild to allow list for postinstall builds 2025-06-06 20:25:46 +02:00
jwetzell c6e38a2b4a upgrade vite to v6 (#1626) 2025-06-05 06:27:59 +02:00
jwetzell 56f18fe71c update node and pnpm versions in README 2025-06-05 06:13:04 +02:00
jwetzell 0b9306de82 update pnpm to latest v10 2025-06-05 06:13:04 +02:00
jwetzell 3b1e6cf33d update node version in workflows 2025-06-05 06:13:04 +02:00
jwetzell dda219ebbe update NodeJS to v22 2025-06-05 06:13:04 +02:00
jwetzell 8bb771e5c6 upgrade electron and electron-builder 2025-06-05 06:13:04 +02:00
Shobhit Nagpal 8723bcbd33 refactor: block event xlsx import (#1623) 2025-06-02 06:56:46 +02:00
Shobhit Nagpal 0dcea4f2d7 fix: pass empty string fallback when parsing fields for export (#1616) 2025-05-25 19:58:41 +02:00
Shobhit Nagpal 696c016c90 feat: custom data for projects (#1571) 2025-05-23 09:04:57 +02:00
Shobhit Nagpal eed6373dbf refactor: change offset classes depending on playback state (#1611)
* refactor: assign offsetClasses based on playback state
2025-05-23 09:04:57 +02:00
Shobhit-Nagpal ac61f257c0 fix: import preview enable after error 2025-05-13 21:24:14 +02:00
Carlos Valente 8d8625aa77 bump version to 3.15.2 2025-05-13 21:24:14 +02:00
Alex Christoffer Rasmussen 1e1aa4bca5 Fix: sheet export (#1606)
* add util to get keys from labels

* look for existing keys first then create new

* save ontime key to rundown metadata

* update test
2025-05-12 13:56:25 +02:00
Alex Christoffer Rasmussen 1f0cde6d0d fix: relative offset with count to end (#1602)
* refactor merge absolute and relative offset calculation

* update test
2025-05-07 22:34:25 +02:00
375 changed files with 16235 additions and 13436 deletions
+6 -6
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -56,12 +56,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -89,12 +89,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+2 -2
View File
@@ -20,13 +20,13 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+4 -4
View File
@@ -17,12 +17,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -73,12 +73,12 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 9 version: 10
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
-5
View File
@@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
-51
View File
@@ -1,51 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="CssUnknownProperty" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myCustomPropertiesEnabled" value="true" />
<option name="myIgnoreVendorSpecificProperties" value="false" />
<option name="myCustomPropertiesList">
<value>
<list size="3">
<item index="0" class="java.lang.String" itemvalue="scrollbar-color" />
<item index="1" class="java.lang.String" itemvalue="scrollbar-width" />
<item index="2" class="java.lang.String" itemvalue="app-region" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://localhost" />
<option value="http://127.0.0.1" />
<option value="http://0.0.0.0" />
<option value="http://www.w3.org/" />
<option value="http://json-schema.org/draft" />
<option value="http://java.sun.com/" />
<option value="http://xmlns.jcp.org/" />
<option value="http://javafx.com/javafx/" />
<option value="http://javafx.com/fxml" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://maven.apache.org/POM/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://primefaces.org/ui" />
<option value="http://tiles.apache.org/" />
<option value="http://__IP__" />
</list>
</option>
</inspection_tool>
<inspection_tool class="Stylelint" enabled="true" level="ERROR" enabled_by_default="true" />
</profile>
</component>
+1 -1
View File
@@ -1 +1 @@
v20.15.1 v22.15.1
+2 -2
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies. The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup Other dependencies will be installed as part of the setup
- __node__ (~20) - __node__ (~22)
- __pnpm__ (~9) - __pnpm__ (~10)
- __docker__ (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
+3 -3
View File
@@ -1,13 +1,13 @@
FROM node:20-bullseye AS builder FROM node:22-bullseye AS builder
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@9.5.0 RUN npm install -g pnpm@10.11.0
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:20-alpine FROM node:22-alpine
# Set environment variables # Set environment variables
# Environment Variable to signal that we are running production # Environment Variable to signal that we are running production
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "3.15.1", "version": "3.15.2",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+10 -10
View File
@@ -1,25 +1,25 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.15.1", "version": "3.15.2",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@chakra-ui/react": "^2.7.0", "@chakra-ui/react": "^2.7.0",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.3.1", "@emotion/is-prop-valid": "^1.3.1",
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.13.3", "@mantine/hooks": "^7.17.2",
"@sentry/react": "^8.43.0", "@sentry/react": "^8.43.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7", "@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.2.0", "axios": "^1.9.0",
"color": "^4.2.3", "color": "^4.2.3",
"csv-stringify": "^6.4.5", "csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0", "framer-motion": "^10.10.0",
@@ -72,7 +72,7 @@
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:", "@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "4.5.1",
"eslint": "catalog:", "eslint": "catalog:",
"eslint-config-prettier": "catalog:", "eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0", "eslint-plugin-jest": "^28.6.0",
@@ -86,10 +86,10 @@
"prettier": "catalog:", "prettier": "catalog:",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "^5.2.11", "vite": "6.3.1",
"vite-plugin-compression2": "^1.3.3", "vite-plugin-compression2": "1.4.0",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "^4.3.1", "vite-tsconfig-paths": "5.1.4",
"vitest": "catalog:" "vitest": "catalog:"
} }
} }
+1 -11
View File
@@ -25,11 +25,10 @@ const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const TimerView = React.lazy(() => import('./views/timer/Timer')); const TimerView = React.lazy(() => import('./views/timer/Timer'));
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock')); const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown')); const Countdown = React.lazy(() => import('./views/countdown/Countdown'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage')); const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage')); const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Public = React.lazy(() => import('./views/public/Public'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird')); const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock')); const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo')); const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
@@ -40,7 +39,6 @@ const SClock = withPreset(withData(ClockView));
const SCountdown = withPreset(withData(Countdown)); const SCountdown = withPreset(withData(Countdown));
const SBackstage = withPreset(withData(Backstage)); const SBackstage = withPreset(withData(Backstage));
const SProjectInfo = withPreset(withData(ProjectInfo)); const SProjectInfo = withPreset(withData(ProjectInfo));
const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower)); const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock)); const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline)); const STimeline = withPreset(withData(Timeline));
@@ -88,14 +86,6 @@ export default function AppRouter() {
</ViewLoader> </ViewLoader>
} }
/> />
<Route
path='/public'
element={
<ViewLoader>
<SPublic />
</ViewLoader>
}
/>
<Route <Route
path='/minimal' path='/minimal'
element={ element={
+2 -6
View File
@@ -10,7 +10,6 @@ export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList']; export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown']; export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore']; export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
export const URL_PRESETS = ['urlpresets']; export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
@@ -19,11 +18,8 @@ export const REPORT = ['report'];
// API URLs // API URLs
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
export const projectDataURL = `${serverURL}/project`; const userAssetsPath = 'user';
export const rundownURL = `${serverURL}/events`; const cssOverridePath = 'styles/override.css';
export const ontimeURL = `${serverURL}/ontime`;
export const userAssetsPath = 'user';
export const cssOverridePath = 'styles/override.css';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
+5 -5
View File
@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { CustomField, CustomFieldLabel, CustomFields } from 'ontime-types'; import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -24,15 +24,15 @@ export async function postCustomField(newField: CustomField): Promise<CustomFiel
/** /**
* Edits single custom field * Edits single custom field
*/ */
export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> { export async function editCustomField(key: CustomFieldKey, newField: CustomField): Promise<CustomFields> {
const res = await axios.put(`${customFieldsPath}/${label}`, { ...newField }); const res = await axios.put(`${customFieldsPath}/${key}`, { ...newField });
return res.data; return res.data;
} }
/** /**
* Deletes single custom field * Deletes single custom field
*/ */
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> { export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
const res = await axios.delete(`${customFieldsPath}/${label}`); const res = await axios.delete(`${customFieldsPath}/${key}`);
return res.data; return res.data;
} }
+5 -3
View File
@@ -2,7 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils'; import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { makeCSVFromArrayOfArrays } from '../utils/csv'; import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
@@ -40,9 +40,11 @@ export async function downloadProject(fileName: string) {
export async function downloadCSV(fileName: string = 'rundown') { export async function downloadCSV(fileName: string = 'rundown') {
try { try {
const { data, name } = await fileDownload(fileName); const { data, name } = await fileDownload(fileName);
const { project, rundown, customFields } = data; const { project, rundowns, customFields } = data;
const flatRundowns = aggregateRundowns(rundowns);
const sheetData = makeTable(project, flatRundowns, customFields);
const sheetData = makeTable(project, rundown, customFields);
const fileContent = makeCSVFromArrayOfArrays(sheetData); const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;'); const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
+5 -6
View File
@@ -1,16 +1,11 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { CustomFields, OntimeRundown } from 'ontime-types'; import { CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const excelPath = `${apiEntryUrl}/excel`; const excelPath = `${apiEntryUrl}/excel`;
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/** /**
* upload Excel file to server * upload Excel file to server
* @return string - file ID op the uploaded file * @return string - file ID op the uploaded file
@@ -34,6 +29,10 @@ export async function getWorksheetNames(): Promise<string[]> {
return response.data; return response.data;
} }
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> { export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, { const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options, options,
+1 -1
View File
@@ -11,7 +11,7 @@ export type HasUpdate = {
* HTTP request to get the latest version and url from github * HTTP request to get the latest version and url from github
*/ */
export async function getLatestVersion(): Promise<HasUpdate> { export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(`${apiRepoLatest}`); const res = await axios.get(apiRepoLatest);
return { return {
url: res.data.html_url as string, url: res.data.html_url as string,
version: res.data.tag_name as string, version: res.data.tag_name as string,
+1 -1
View File
@@ -11,7 +11,7 @@ export const reportUrl = `${apiEntryUrl}/report`;
* HTTP request to fetch all reports * HTTP request to fetch all reports
*/ */
export async function fetchReport(): Promise<OntimeReport> { export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(`${reportUrl}/`); const res = await axios.get(reportUrl);
return res.data; return res.data;
} }
+57 -20
View File
@@ -1,29 +1,45 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types'; import {
EntryId,
MessageResponse,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`; const rundownPath = `${apiEntryUrl}/rundown`;
/** /**
* HTTP request to fetch all events * HTTP request to fetch a list of existing rundowns
*/ */
export async function fetchNormalisedRundown(): Promise<RundownCached> { export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(`${rundownPath}/normalised`); const res = await axios.get(rundownPath);
return res.data; return res.data;
} }
/** /**
* HTTP request to post new event * HTTP request to fetch all events
*/ */
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> { export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
return res.data;
}
/**
* HTTP request to post new entry
*/
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(rundownPath, data); return axios.post(rundownPath, data);
} }
/** /**
* HTTP request to put new event * HTTP request to edit an entry
*/ */
export async function requestPutEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> { export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(rundownPath, data); return axios.put(rundownPath, data);
} }
@@ -33,22 +49,22 @@ type BatchEditEntry = {
}; };
/** /**
* HTTP request to put multiple events * HTTP request to edit multiple events
*/ */
export async function requestBatchPutEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> { export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/batch`, data); return axios.put(`${rundownPath}/batch`, data);
} }
export type ReorderEntry = { export type ReorderEntry = {
eventId: string; entryId: EntryId;
from: number; destinationId: EntryId;
to: number; order: 'before' | 'after' | 'insert';
}; };
/** /**
* HTTP request to reorder events * HTTP request to reorder an entry
*/ */
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeRundownEntry>> { export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/reorder`, data); return axios.patch(`${rundownPath}/reorder`, data);
} }
@@ -67,15 +83,36 @@ export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<M
/** /**
* HTTP request to request application of delay * HTTP request to request application of delay
*/ */
export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<MessageResponse>> { export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/applydelay/${eventId}`); return axios.patch(`${rundownPath}/applydelay/${delayId}`);
} }
/** /**
* HTTP request to delete given event * HTTP request for cloning an entry
*/ */
export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> { export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.delete(rundownPath, { data: { ids: eventIds } }); return axios.post(`${rundownPath}/clone/${entryId}`);
}
/**
* HTTP request for dissolving of a block
*/
export async function requestUngroup(blockId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/ungroup/${blockId}`);
}
/**
* HTTP request for grouping a list of entries into a block
*/
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/group`, { ids: entryIds });
}
/**
* HTTP request to delete entries
*/
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: entryIds } });
} }
/** /**
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -54,7 +54,7 @@ export const previewRundown = async (
sheetId: string, sheetId: string,
options: ImportMap, options: ImportMap,
): Promise<{ ): Promise<{
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
}> => { }> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
+2 -2
View File
@@ -7,7 +7,7 @@ import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time'; import { nowInMillis } from '../utils/time';
/** /**
* Utility unrwap a potential axios error * Utility unwrap a potential axios error
* @param error * @param error
* @returns * @returns
*/ */
@@ -34,7 +34,7 @@ export function maybeAxiosError(error: unknown) {
} }
/** /**
* Utility unrwaps a potential axios error and sends to logger * Utility unwraps a potential axios error and sends to logger
* @param prepend * @param prepend
* @param error * @param error
*/ */
@@ -0,0 +1,61 @@
@use '../../../theme/viewerDefs' as *;
.baseButton {
display: flex;
align-items: center;
gap: 0.5em;
width: fit-content;
padding-inline: 1em;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.subtle {
background: $gray-1050;
color: $blue-400;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
}
.primary {
background: $blue-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $blue-600;
}
&:active:not(:disabled) {
background: $blue-800;
border-color: $blue-900;
}
&:disabled {
opacity: $viewer-opacity-disabled;
}
}
.medium {
height: 2rem;
}
.large {
height: 3.5rem;
}
@@ -0,0 +1,20 @@
import { ButtonHTMLAttributes } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './Button.module.scss';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'subtle' | 'primary';
size?: 'medium' | 'large';
}
export default function Button(props: ButtonProps) {
const { className, children, variant = 'subtle', size = 'medium', ...buttonProps } = props;
return (
<button className={cx([style.baseButton, style[variant], style[size], className])} type='button' {...buttonProps}>
{children}
</button>
);
}
@@ -1,21 +1,28 @@
.subtle { @use '../../../theme/viewerDefs' as *;
.baseIconButton {
aspect-ratio: 1; aspect-ratio: 1;
height: 2rem; height: 2rem;
height: 2rem; width: 2rem;
display: grid; display: grid;
place-content: center; place-content: center;
background: $gray-1050;
color: $blue-400;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 3px; border-radius: 3px;
cursor: pointer;
}
.subtle {
background: $gray-1050;
color: $blue-400;
&:hover:not(:disabled):not(:active) { &:hover:not(:disabled):not(:active) {
background: $gray-1000; background: $gray-1000;
color: $blue-500; color: $blue-500;
} }
&:active { &:active:not(:disabled){
background: $gray-1100; background: $gray-1100;
border-color: $gray-1250; border-color: $gray-1250;
} }
@@ -24,3 +31,37 @@
background: $gray-1050; background: $gray-1050;
} }
} }
.subtle-white {
background: $gray-1050;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
background: $gray-1050;
}
}
.destructive {
background: $red-700;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $red-600;
color: $ui-white;
}
&:active:not(:disabled) {
background: $red-800;
border-color: $red-900;
}
}
@@ -4,10 +4,15 @@ import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss'; import style from './IconButton.module.scss';
export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) { interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
const { className, children, ...buttonProps } = props; variant?: 'subtle' | 'subtle-white' | 'destructive';
}
export default function IconButton(props: IconButtonProps) {
const { className, children, variant = 'subtle', ...buttonProps } = props;
return ( return (
<button className={cx([style.subtle, className])} {...buttonProps}> <button className={cx([style.baseIconButton, style[variant], className])} type='button' {...buttonProps}>
{children} {children}
</button> </button>
); );
@@ -23,7 +23,7 @@ export type OptionWithoutGroup = {
withDivider?: boolean; withDivider?: boolean;
}; };
export type OptionWithGroup = { type OptionWithGroup = {
label: string; label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[]; group: Omit<OptionWithoutGroup, 'isGroup'>[];
}; };
@@ -3,8 +3,8 @@ import React from 'react';
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module. // skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { runtimeStore } from '@/common/stores/runtime'; import { hasConnected, reconnectAttempts } from '../../../common/utils/socket';
import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket'; import { runtimeStore } from '../../stores/runtime';
import style from './ErrorBoundary.module.scss'; import style from './ErrorBoundary.module.scss';
@@ -37,7 +37,7 @@ class ErrorBoundary extends React.Component {
scope.setExtras({ scope.setExtras({
error, error,
store: appState, store: appState,
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts }, hasSocket: { hasConnected, reconnectAttempts },
}); });
const eventId = Sentry.captureException(error); const eventId = Sentry.captureException(error);
this.setState({ eventId, info }); this.setState({ eventId, info });
@@ -2,7 +2,7 @@ import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react'; import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import { useEventAction } from '../../../hooks/useEventAction'; import { useEntryActions } from '../../../hooks/useEntryAction';
import style from './DelayInput.module.scss'; import style from './DelayInput.module.scss';
@@ -13,7 +13,7 @@ interface DelayInputProps {
export default function DelayInput(props: DelayInputProps) { export default function DelayInput(props: DelayInputProps) {
const { eventId, duration } = props; const { eventId, duration } = props;
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
@@ -54,7 +54,7 @@ export default function DelayInput(props: DelayInputProps) {
}; };
const submitChange = (value: number) => { const submitChange = (value: number) => {
updateEvent({ updateEntry({
id: eventId, id: eventId,
duration: value, duration: value,
}); });
@@ -1,45 +0,0 @@
$loader-size: 4rem;
.overlay {
position: absolute;
z-index: 10;
width: 100%;
height: 100%;
display: grid;
place-content: center;
background-color: $black-10;
backdrop-filter: blur(5px);
}
.loader {
width: $loader-size;
height: $loader-size;
background: $blue-500;
display: inline-block;
border-radius: 50%;
box-sizing: border-box;
animation: animloader 1s ease-in infinite;
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@@ -1,9 +0,0 @@
import style from './LoaderOverlay.module.scss';
export default function LoaderOverlay() {
return (
<div className={style.overlay}>
<span className={style.loader} />
</div>
);
}
@@ -3,6 +3,7 @@ import { IoSettingsOutline } from 'react-icons/io5';
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import IconButton from '../buttons/IconButton';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -17,22 +18,24 @@ export default function FloatingNavigation(props: FloatingNavigationProps) {
return ( return (
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}> <div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
<button <IconButton
variant='subtle-white'
className={style.navButton}
onClick={toggleMenu} onClick={toggleMenu}
aria-label='toggle menu' aria-label='toggle menu'
className={style.navButton}
data-testid='navigation__toggle-menu' data-testid='navigation__toggle-menu'
> >
<IoApps /> <IoApps />
</button> </IconButton>
<button <IconButton
className={style.button} variant='subtle-white'
className={style.navButton}
onClick={toggleSettings} onClick={toggleSettings}
aria-label='toggle settings' aria-label='toggle settings'
data-testid='navigation__toggle-settings' data-testid='navigation__toggle-settings'
> >
<IoSettingsOutline /> <IoSettingsOutline />
</button> </IconButton>
</div> </div>
); );
} }
@@ -3,8 +3,6 @@
$menu-hover-bg: $gray-1350; $menu-hover-bg: $gray-1350;
$menu-focus-bg: $gray-1300; $menu-focus-bg: $gray-1300;
$icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 3rem; $button-size: 3rem;
.fadeable { .fadeable {
@@ -15,6 +13,7 @@ $button-size: 3rem;
&.hidden { &.hidden {
opacity: 0; opacity: 0;
pointer-events: none;
} }
} }
@@ -41,20 +40,10 @@ $button-size: 3rem;
z-index: 12; z-index: 12;
} }
.button {
font-size: 1.5rem;
color: $icon-color;
background-color: $button-bg;
width: $button-size;
height: $button-size;
display: grid;
place-content: center;
border-radius: 3px;
}
.navButton { .navButton {
@extend .button; font-size: 1.5rem;
z-index: 3; height: $button-size;
width: $button-size;;
} }
.link { .link {
@@ -1,16 +0,0 @@
import { memo } from 'react';
import NavigationMenu from './NavigationMenu';
interface ProductionNavigationMenuProps {
isMenuOpen: boolean;
onMenuClose: () => void;
}
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
const { isMenuOpen, onMenuClose } = props;
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
}
export default memo(ProductionNavigationMenu);
@@ -12,7 +12,7 @@ type OptionsField = {
defaultValue?: string; defaultValue?: string;
}; };
export type MultiselectOption = { value: string; label: string; colour: string }; type MultiselectOption = { value: string; label: string; colour: string };
export type MultiselectOptions = Record<string, MultiselectOption>; export type MultiselectOptions = Record<string, MultiselectOption>;
type MultiOptionsField = { type MultiOptionsField = {
type: 'multi-option'; type: 'multi-option';
@@ -1,32 +0,0 @@
// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts
import { useCallback, useEffect, useState } from 'react';
function getMatches(query: string): boolean {
return window.matchMedia(query).matches;
}
// TODO: debounce handleChange
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(getMatches(query));
const handleChange = useCallback(() => {
setMatches(getMatches(query));
}, [query]);
useEffect(() => {
const matchMedia = window.matchMedia(query);
// Triggered at the first client-side load and if query changes
handleChange();
// Listen matchMedia
matchMedia.addEventListener('change', handleChange);
return () => {
matchMedia.removeEventListener('change', handleChange);
};
}, [handleChange, query]);
return matches;
}
@@ -1,11 +1,9 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { editAutomationSettings, getAutomationSettings } from '../api/automation'; import { getAutomationSettings } from '../api/automation';
import { AUTOMATION } from '../api/constants'; import { AUTOMATION } from '../api/constants';
import { logAxiosError } from '../api/utils';
import { automationPlaceholderSettings } from '../models/AutomationSettings'; import { automationPlaceholderSettings } from '../models/AutomationSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useAutomationSettings() { export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({ const { data, status, isFetching, isError, refetch } = useQuery({
@@ -20,15 +18,3 @@ export default function useAutomationSettings() {
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch }; return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
} }
export function useAutomationSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: editAutomationSettings,
onError: (error) => logAxiosError('Error saving Automation settings', error),
onSuccess: (data) => {
ontimeQueryClient.setQueryData(AUTOMATION, data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }),
});
return { isPending, mutateAsync };
}
@@ -11,7 +11,7 @@ const placeholderProjectList: ProjectFileListResponse = {
lastLoadedProject: '', lastLoadedProject: '',
}; };
export function useProjectList() { function useProjectList() {
const { data, status, refetch } = useQuery({ const { data, status, refetch } = useQuery({
queryKey: PROJECT_LIST, queryKey: PROJECT_LIST,
queryFn: getProjects, queryFn: getProjects,
@@ -1,23 +1,30 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types'; import { OntimeEntry, Rundown } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown'; import { fetchCurrentRundown } from '../api/rundown';
import useProjectData from './useProjectData'; import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher // revision is -1 so that the remote revision is higher
const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 }; const cachedRundownPlaceholder: Rundown = {
id: 'default',
title: '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
};
/** /**
* Normalised rundown data * Normalised rundown data
*/ */
export default function useRundown() { export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchNormalisedRundown, queryFn: fetchCurrentRundown,
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
@@ -37,16 +44,16 @@ export function useFlatRundown() {
const loadedProject = useRef<string>(''); const loadedProject = useRef<string>('');
const [prevRevision, setPrevRevision] = useState<number>(-1); const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]); const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]);
// update data whenever the revision changes // update data whenever the revision changes
useEffect(() => { useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) { if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.rundown[id]); const flatRundown = data.flatOrder.map((id) => data.entries[id]);
setFlatRunDown(flatRundown); setFlatRundown(flatRundown);
setPrevRevision(data.revision); setPrevRevision(data.revision);
} }
}, [data.order, data.revision, data.rundown, prevRevision]); }, [data.entries, data.flatOrder, data.revision, prevRevision]);
// TODO: should we have a project id field? // TODO: should we have a project id field?
// invalidate current version if project changes // invalidate current version if project changes
@@ -57,13 +64,13 @@ export function useFlatRundown() {
} }
}, [projectData]); }, [projectData]);
return { data: flatRunDown, status }; return { data: flatRundown, status };
} }
/** /**
* Provides access to a partial rundown based on a filter callback * Provides access to a partial rundown based on a filter callback
*/ */
export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) { export function usePartialRundown(cb: (event: OntimeEntry) => boolean) {
const { data, status } = useFlatRundown(); const { data, status } = useFlatRundown();
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return data.filter(cb); return data.filter(cb);
@@ -0,0 +1,806 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
isOntimeBlock,
isOntimeEvent,
MaybeString,
OntimeBlock,
OntimeEntry,
OntimeEvent,
Rundown,
TimeField,
TimeStrategy,
TransientEventPayload,
} from 'ontime-types';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
import { moveDown, moveUp } from '../../features/rundown/rundown.utils';
import { RUNDOWN } from '../api/constants';
import {
deleteEntries,
patchReorderEntry,
postAddEntry,
postCloneEntry,
putBatchEditEvents,
putEditEntry,
ReorderEntry,
requestApplyDelay,
requestDeleteAll,
requestEventSwap,
requestGroupEntries,
requestUngroup,
SwapEntry,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
// options to any new block (event / delay / block)
after: MaybeString;
before: MaybeString;
// options to blocks of type OntimeEvent
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: MaybeString;
}>;
/**
* Gather utilities for actions on entries
*/
export const useEntryActions = () => {
const queryClient = useQueryClient();
const {
defaultPublic,
linkPrevious,
defaultTimeStrategy,
defaultDuration,
defaultWarnTime,
defaultDangerTime,
defaultTimerType,
defaultEndAction,
} = useEditorSettings();
const getEntryById = useCallback(
(eventId: string): OntimeEntry | undefined => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.entries) {
return;
}
return cachedRundown.entries[eventId];
},
[queryClient],
);
/**
* Calls mutation to add new entry
* @private
*/
const _addEntryMutation = useMutation({
// TODO(v4): optimistic create entry
mutationFn: postAddEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Adds an entry to rundown
*/
const addEntry = useCallback(
async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
// merge creation time options with event settings
const applicationOptions = {
after: options?.after,
before: options?.before,
defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious,
};
if (applicationOptions?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
const previousEvent = rundownData.entries[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd;
}
}
// Override event with options from editor settings
newEntry.linkStart = applicationOptions.linkPrevious;
newEntry.isPublic = applicationOptions.defaultPublic;
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
newEntry.duration = parseUserTime(defaultDuration);
}
if (newEntry.timeDanger === undefined) {
newEntry.timeDanger = parseUserTime(defaultDangerTime);
}
if (newEntry.timeWarning === undefined) {
newEntry.timeWarning = parseUserTime(defaultWarnTime);
}
if (newEntry.timerType === undefined) {
newEntry.timerType = defaultTimerType;
}
if (newEntry.endAction === undefined) {
newEntry.endAction = defaultEndAction;
}
if (newEntry.timeStrategy === undefined) {
newEntry.timeStrategy = defaultTimeStrategy;
}
}
// handle adding options that concern all event type
if (options?.after) {
(newEntry as TransientEventPayload).after = options.after;
}
if (options?.before) {
(newEntry as TransientEventPayload).before = options.before;
}
try {
await _addEntryMutation.mutateAsync(newEntry);
} catch (error) {
logAxiosError('Failed adding event', error);
}
},
[
_addEntryMutation,
defaultDangerTime,
defaultDuration,
defaultEndAction,
defaultPublic,
defaultTimerType,
defaultTimeStrategy,
defaultWarnTime,
linkPrevious,
queryClient,
],
);
/**
* Calls mutation to clone a selection
* @private
*/
const _cloneMutation = useMutation({
mutationFn: postCloneEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Clone a selection
*/
const clone = useCallback(
async (entryId: EntryId) => {
try {
await _cloneMutation.mutateAsync(entryId);
} catch (error) {
logAxiosError('Error cloning entry', error);
}
},
[_cloneMutation],
);
/**
* Calls mutation to update existing entry
* @private
*/
const _updateEntryMutation = useMutation({
mutationFn: putEditEntry,
// we optimistically update here
onMutate: async (newEvent) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
const eventId = newEvent.id;
if (previousData && eventId) {
// optimistically update object
const newRundown = { ...previousData.entries };
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: previousData.order,
flatOrder: previousData.flatOrder,
entries: newRundown,
revision: -1,
});
}
// Return a context with the previous and new events
return { previousData, newEvent };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
});
/**
* Updates existing entry
*/
const updateEntry = useCallback(
async (event: Partial<OntimeEntry>) => {
try {
await _updateEntryMutation.mutateAsync(event);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEntryMutation],
);
const updateCustomField = useCallback(
async (entryId: EntryId, field: string, value: string) => {
updateEntry({ id: entryId, custom: { [field]: value } });
},
[updateEntry],
);
/**
* Updates time of existing event
* @param eventId {EntryId} - id of the event
* @param field {TimeField} - field to update
* @param value {string} - new value string to be parsed
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
*/
const updateTimer = useCallback(
async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => {
// an empty value with no lock has no domain validity
if (!lockOnUpdate && value === '') {
return;
}
const newEvent: Partial<OntimeEvent> = {
id: eventId,
};
// check if we should lock the field
if (lockOnUpdate) {
if (field === 'timeEnd') {
// an empty value indicates that we should unlock the field
newEvent.timeStrategy = value === '' ? TimeStrategy.LockDuration : TimeStrategy.LockEnd;
newEvent.timeEnd = value === '' ? undefined : calculateNewValue();
} else if (field === 'duration') {
// an empty value indicates that we should unlock the field
newEvent.timeStrategy = value === '' ? TimeStrategy.LockEnd : TimeStrategy.LockDuration;
newEvent.duration = value === '' ? undefined : calculateNewValue();
} else if (field === 'timeStart') {
// an empty values means we should link to the previous
newEvent.linkStart = value === '';
newEvent.timeStart = value === '' ? undefined : calculateNewValue();
}
} else {
newEvent[field] = calculateNewValue();
}
try {
await _updateEntryMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
/**
* Utility function to calculate the new time value
*/
function calculateNewValue(): number {
let newValMillis = 0;
// check for previous keyword
if (value === 'p' || value === 'prev' || value === 'previous') {
newValMillis = getPreviousEnd();
// check for adding time keyword
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
} else {
newValMillis = parseUserTime(value);
}
// dont allow timer values over 23:59:59
return Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
}
/**
* Utility function to get the previous event end time
*/
function getPreviousEnd(): number {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.entries) {
return 0;
}
const index = cachedRundown.order.indexOf(eventId);
if (index === 0) {
return 0;
}
let previousEnd = 0;
for (let i = index - 1; i >= 0; i--) {
const event = cachedRundown.entries[cachedRundown.order[i]];
if (isOntimeEvent(event)) {
previousEnd = event.timeEnd;
break;
}
}
return previousEnd;
}
},
[_updateEntryMutation, queryClient],
);
/**
* Calls mutation to edit multiple events
* @private
*/
const _batchUpdateEventsMutation = useMutation({
mutationFn: putBatchEditEvents,
onMutate: async ({ ids, data }) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousRundown) {
const eventIds = new Set(ids);
const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = {
...event,
...data,
};
}
}
});
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousRundown.id,
title: previousRundown.title,
order: previousRundown.order,
flatOrder: previousRundown.flatOrder,
entries: newRundown,
revision: -1,
});
}
// Return a context with the previous rundown
return { previousRundown };
},
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
onError: (_error, _newEvent, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
},
});
const batchUpdateEvents = useCallback(
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
try {
await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
} catch (error) {
logAxiosError('Error updating events', error);
}
},
[_batchUpdateEventsMutation],
);
/**
* Calls mutation to delete an entry
* @private
*/
const _deleteEntryMutation = useMutation({
mutationFn: deleteEntries,
// we optimistically update here
onMutate: async (entryIds: EntryId[]) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) {
// optimistically update object
const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData);
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order,
flatOrder,
entries,
revision: -1,
});
}
// Return a context with the previous and new events
return { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _entryIds, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
});
/**
* Deletes an event entry from the rundown
*/
const deleteEntry = useCallback(
async (entryIds: EntryId[]) => {
try {
await _deleteEntryMutation.mutateAsync(entryIds);
} catch (error) {
logAxiosError('Error deleting event', error);
}
},
[_deleteEntryMutation],
);
/**
* Calls mutation to delete all events
* @private
*/
const _deleteAllEntriesMutation = useMutation({
mutationFn: requestDeleteAll,
// we optimistically update here
onMutate: async () => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
// optimistically update object
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData?.id ?? 'default',
title: previousData?.title ?? '',
order: [],
flatOrder: [],
entries: {},
revision: -1,
});
// Return a context with the previous and new events
return { previousData };
},
// Mutation fails, rollback optimist update
onError: (_error, _, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
});
/**
* Deletes all entries in the rundown
*/
const deleteAllEntries = useCallback(async () => {
try {
await _deleteAllEntriesMutation.mutateAsync();
} catch (error) {
logAxiosError('Error deleting events', error);
}
}, [_deleteAllEntriesMutation]);
/**
* Calls mutation to apply a delay
* @private
*/
const _applyDelayMutation = useMutation({
mutationFn: requestApplyDelay,
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
// Mutation finished, failed or successful
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
});
/**
* Applies a given delay
*/
const applyDelay = useCallback(
async (delayEventId: EntryId) => {
try {
await _applyDelayMutation.mutateAsync(delayEventId);
} catch (error) {
logAxiosError('Error applying delay', error);
}
},
[_applyDelayMutation],
);
/**
* Calls mutation to dissolve a block
* @private
*/
const _ungroupMutation = useMutation({
mutationFn: requestUngroup,
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Deletes a block and moves its events to the top level
*/
const ungroup = useCallback(
async (blockId: EntryId) => {
try {
await _ungroupMutation.mutateAsync(blockId);
} catch (error) {
logAxiosError('Error dissolving block', error);
}
},
[_ungroupMutation],
);
/**
* Calls mutation to create a block with a selection
* @private
*/
const _groupEntriesMutation = useMutation({
mutationFn: requestGroupEntries,
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Create a block with a selection
*/
const groupEntries = useCallback(
async (entryIds: EntryId[]) => {
try {
await _groupEntriesMutation.mutateAsync(entryIds);
} catch (error) {
logAxiosError('Error grouping entries', error);
}
},
[_groupEntriesMutation],
);
/**
* Calls mutation to reorder an entry
* @private
*/
const _reorderEntryMutation = useMutation({
mutationFn: patchReorderEntry,
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
});
/**
* Reorders a given entry
*/
const reorderEntry = useCallback(
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
try {
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order,
};
await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
},
[_reorderEntryMutation],
);
const move = useCallback(async (entryId: EntryId, direction: 'up' | 'down') => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.order) {
return;
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, cachedRundown.order, cachedRundown.entries)
: moveDown(entryId, cachedRundown.order, cachedRundown.entries);
if (destinationId) {
try {
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order: order as 'before' | 'after' | 'insert',
};
await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
}
}, []);
/**
* Calls mutation to swap events
* @private
*/
const _swapEvents = useMutation({
mutationFn: requestEventSwap,
// we optimistically update here
onMutate: async ({ from, to }) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) {
// optimistically update object
const newRundown = { ...previousData.entries };
const eventA = previousData.entries[from];
const eventB = previousData.entries[to];
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return;
}
const [newA, newB] = swapEventData(eventA, eventB);
newRundown[from] = newA;
newRundown[to] = newB;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: previousData.order,
flatOrder: previousData.flatOrder,
entries: newRundown,
revision: -1,
});
}
// Return a context with the previous events
return { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
});
/**
* Swaps the schedule of two events
*/
const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => {
try {
await _swapEvents.mutateAsync({ from, to });
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
},
[_swapEvents],
);
return {
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
updateEntry,
updateTimer,
updateCustomField,
};
};
/**
* Utility to optimistically delete entries from client cache
*/
function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
const entries = { ...rundown.entries };
let order = [...rundown.order];
let flatOrder = [...rundown.flatOrder];
for (let i = 0; i < entryIds.length; i++) {
const entry = entries[entryIds[i]];
deleteEntry(entry);
}
function deleteEntry(entry: OntimeEntry) {
if (isOntimeBlock(entry) || !entry.parent) {
order = order.filter((id) => id !== entry.id);
} else {
const parent = entries[entry.parent] as OntimeBlock;
parent.events = parent.events.filter((event) => event !== entry.id);
}
delete entries[entry.id];
flatOrder = flatOrder.filter((id) => id !== entry.id);
}
return { entries, order, flatOrder };
}
@@ -1,636 +0,0 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
isOntimeEvent,
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundownEntry,
RundownCached,
TimeField,
TimeStrategy,
TransientEventPayload,
} from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/constants';
import {
ReorderEntry,
requestApplyDelay,
requestBatchPutEvents,
requestDelete,
requestDeleteAll,
requestEventSwap,
requestPostEvent,
requestPutEvent,
requestReorderEvent,
SwapEntry,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{
// options to any new block (event / delay / block)
after: string;
before: string;
// options to blocks of type OntimeEvent
defaultPublic: boolean;
linkPrevious: boolean;
lastEventId: string;
}>;
/**
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
*/
export const useEventAction = () => {
const queryClient = useQueryClient();
const {
defaultPublic,
linkPrevious,
defaultTimeStrategy,
defaultDuration,
defaultWarnTime,
defaultDangerTime,
defaultTimerType,
defaultEndAction,
} = useEditorSettings();
const getEventById = useCallback(
(eventId: string) => {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.rundown) {
return;
}
return cachedRundown.rundown[eventId];
},
[queryClient],
);
/**
* Calls mutation to add new event
* @private
*/
const _addEventMutation = useMutation({
mutationFn: requestPostEvent,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
* Adds an event to rundown
*/
const addEvent = useCallback(
async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => {
const newEvent: TransientEventPayload = { ...event };
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) {
// merge creation time options with event settings
const applicationOptions = {
after: options?.after,
before: options?.before,
defaultPublic: options?.defaultPublic ?? defaultPublic,
lastEventId: options?.lastEventId,
linkPrevious: options?.linkPrevious ?? linkPrevious,
};
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
newEvent.linkStart = applicationOptions.lastEventId;
} else if (applicationOptions?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
const { rundown } = rundownData;
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd;
}
}
// Override event with options from editor settings
if (applicationOptions.defaultPublic) {
newEvent.isPublic = true;
}
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
newEvent.duration = parseUserTime(defaultDuration);
}
if (newEvent.timeDanger === undefined) {
newEvent.timeDanger = parseUserTime(defaultDangerTime);
}
if (newEvent.timeWarning === undefined) {
newEvent.timeWarning = parseUserTime(defaultWarnTime);
}
if (newEvent.timerType === undefined) {
newEvent.timerType = defaultTimerType;
}
if (newEvent.endAction === undefined) {
newEvent.endAction = defaultEndAction;
}
if (newEvent.timeStrategy === undefined) {
newEvent.timeStrategy = defaultTimeStrategy;
}
}
// handle adding options that concern all event type
if (options?.after) {
// @ts-expect-error -- not sure how to type this, <after> is a transient property
newEvent.after = options.after;
}
if (options?.before) {
// @ts-expect-error -- not sure how to type this, <before> is a transient property
newEvent.before = options.before;
}
try {
await _addEventMutation.mutateAsync(newEvent as TransientEventPayload);
} catch (error) {
logAxiosError('Failed adding event', error);
}
},
[
_addEventMutation,
defaultDangerTime,
defaultDuration,
defaultEndAction,
defaultPublic,
defaultTimerType,
defaultTimeStrategy,
defaultWarnTime,
linkPrevious,
queryClient,
],
);
/**
* Calls mutation to update existing event
* @private
*/
const _updateEventMutation = useMutation({
mutationFn: requestPutEvent,
// we optimistically update here
onMutate: async (newEvent) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
const eventId = newEvent.id;
if (previousData && eventId) {
// optimistically update object
const newRundown = { ...previousData.rundown };
// @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous and new events
return { previousData, newEvent };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
* Updates existing event
*/
const updateEvent = useCallback(
async (event: Partial<OntimeRundownEntry>) => {
try {
await _updateEventMutation.mutateAsync(event);
} catch (error) {
logAxiosError('Error updating event', error);
}
},
[_updateEventMutation],
);
const updateCustomField = useCallback(
async (eventId: string, field: string, value: string) => {
updateEvent({ id: eventId, custom: { [field]: value } });
},
[updateEvent],
);
/**
* Updates time of existing event
* @param eventId {string} - id of the event
* @param field {TimeField} - field to update
* @param value {string} - new value string to be parsed
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
*/
const updateTimer = useCallback(
async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => {
// an empty value with no lock has no domain validity
if (!lockOnUpdate && value === '') {
return;
}
const newEvent: Partial<OntimeEvent> = {
id: eventId,
};
// check if we should lock the field
if (lockOnUpdate) {
if (field === 'timeEnd') {
// an empty value indicates that we should unlock the field
newEvent.timeStrategy = value === '' ? TimeStrategy.LockDuration : TimeStrategy.LockEnd;
newEvent.timeEnd = value === '' ? undefined : calculateNewValue();
} else if (field === 'duration') {
// an empty value indicates that we should unlock the field
newEvent.timeStrategy = value === '' ? TimeStrategy.LockEnd : TimeStrategy.LockDuration;
newEvent.duration = value === '' ? undefined : calculateNewValue();
} else if (field === 'timeStart') {
// an empty values means we should link to the previous
newEvent.linkStart = value === '' ? 'true' : null;
newEvent.timeStart = value === '' ? undefined : calculateNewValue();
}
} else {
newEvent[field] = calculateNewValue();
}
try {
await _updateEventMutation.mutateAsync(newEvent);
} catch (error) {
logAxiosError('Error updating event', error);
}
/**
* Utility function to calculate the new time value
*/
function calculateNewValue(): number {
let newValMillis = 0;
// check for previous keyword
if (value === 'p' || value === 'prev' || value === 'previous') {
newValMillis = getPreviousEnd();
// check for adding time keyword
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
} else {
newValMillis = parseUserTime(value);
}
// dont allow timer values over 23:59:59
return Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
}
/**
* Utility function to get the previous event end time
*/
function getPreviousEnd(): number {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) {
return 0;
}
const index = cachedRundown.order.indexOf(eventId);
if (index === 0) {
return 0;
}
let previousEnd = 0;
for (let i = index - 1; i >= 0; i--) {
const event = cachedRundown.rundown[cachedRundown.order[i]];
if (isOntimeEvent(event)) {
previousEnd = event.timeEnd;
break;
}
}
return previousEnd;
}
},
[_updateEventMutation, queryClient],
);
/**
* Calls mutation to edit multiple events
* @private
*/
const _batchUpdateEventsMutation = useMutation({
mutationFn: requestBatchPutEvents,
onMutate: async ({ ids, data }) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousEvents) {
const eventIds = new Set(ids);
const newRundown = { ...previousEvents.rundown };
eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
newRundown[eventId] = {
...event,
...data,
};
}
}
});
queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous and new events
return { previousEvents };
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousEvents);
},
networkMode: 'always',
});
const batchUpdateEvents = useCallback(
async (data: Partial<OntimeEvent>, eventIds: string[]) => {
try {
await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data });
} catch (error) {
logAxiosError('Error updating events', error);
}
},
[_batchUpdateEventsMutation],
);
/**
* Calls mutation to delete an event
* @private
*/
const _deleteEventMutation = useMutation({
mutationFn: requestDelete,
// we optimistically update here
onMutate: async (eventIds: string[]) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
const newRundown = { ...previousData.rundown };
for (const eventId of eventIds) {
delete newRundown[eventId];
}
queryClient.setQueryData(RUNDOWN, {
order: newOrder,
rundown: newRundown,
revision: -1,
});
}
// Return a context with the previous and new events
return { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
* Deletes an event form the list
*/
const deleteEvent = useCallback(
async (eventIds: string[]) => {
try {
await _deleteEventMutation.mutateAsync(eventIds);
} catch (error) {
logAxiosError('Error deleting event', error);
}
},
[_deleteEventMutation],
);
/**
* Calls mutation to delete all events
* @private
*/
const _deleteAllEventsMutation = useMutation({
mutationFn: requestDeleteAll,
// we optimistically update here
onMutate: async () => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
// optimistically update object
queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 });
// Return a context with the previous and new events
return { previousData };
},
// Mutation fails, rollback undos optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
* Deletes all events from list
*/
const deleteAllEvents = useCallback(async () => {
try {
await _deleteAllEventsMutation.mutateAsync();
} catch (error) {
logAxiosError('Error deleting events', error);
}
}, [_deleteAllEventsMutation]);
/**
* Calls mutation to apply a delay
* @private
*/
const _applyDelayMutation = useMutation({
mutationFn: requestApplyDelay,
// Mutation finished, failed or successful
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
* Applies a given delay block
*/
const applyDelay = useCallback(
async (delayEventId: string) => {
try {
await _applyDelayMutation.mutateAsync(delayEventId);
} catch (error) {
logAxiosError('Error applying delay', error);
}
},
[_applyDelayMutation],
);
/**
* Calls mutation to reorder an event
* @private
*/
const _reorderEventMutation = useMutation({
mutationFn: requestReorderEvent,
// we optimistically update here
onMutate: async (data) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const newOrder = reorderArray(previousData.order, data.from, data.to);
queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 });
}
// Return a context with the previous and new events
return { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
* Reorders a given event
*/
const reorderEvent = useCallback(
async (eventId: string, from: number, to: number) => {
try {
const reorderObject: ReorderEntry = {
eventId,
from,
to,
};
await _reorderEventMutation.mutateAsync(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
},
[_reorderEventMutation],
);
/**
* Calls mutation to swap events
* @private
*/
const _swapEvents = useMutation({
mutationFn: requestEventSwap,
// we optimistically update here
onMutate: async ({ from, to }) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN);
if (previousData) {
// optimistically update object
const newRundown = { ...previousData.rundown };
const eventA = previousData.rundown[from];
const eventB = previousData.rundown[to];
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return;
}
const { newA, newB } = swapEventData(eventA, eventB);
newRundown[from] = newA;
newRundown[to] = newB;
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 });
}
// Return a context with the previous events
return { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
* Swaps the schedule of two events
*/
const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => {
try {
await _swapEvents.mutateAsync({ from, to });
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
},
[_swapEvents],
);
return {
addEvent,
applyDelay,
batchUpdateEvents,
deleteEvent,
deleteAllEvents,
getEventById,
reorderEvent,
swapEvents,
updateEvent,
updateTimer,
updateCustomField,
};
};
@@ -2,31 +2,38 @@ import { useEffect, useState } from 'react';
import { throttle } from '../utils/throttle'; import { throttle } from '../utils/throttle';
const inactiveTime = 3000; // 3 seconds
export const useFadeOutOnInactivity = () => { /**
const [isMouseMoved, setIsMouseMoved] = useState(false); * Provides whether there has been mouse movement in the page in the last <inactiveTime>
*/
export const useFadeOutOnInactivity = (initialState = false) => {
const [isUserActive, setIsUserActive] = useState(initialState);
// show on mouse move // show on mouse move
useEffect(() => { useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null; let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => { const setShowMenuTrue = () => {
setIsMouseMoved(true); setIsUserActive(true);
if (fadeOut) { if (fadeOut) {
clearTimeout(fadeOut); clearTimeout(fadeOut);
} }
fadeOut = setTimeout(() => setIsMouseMoved(false), 3000); fadeOut = setTimeout(() => setIsUserActive(false), inactiveTime);
}; };
const throttledShowMenu = throttle(setShowMenuTrue, 1000); const throttledShowMenu = throttle(setShowMenuTrue, 1000);
document.addEventListener('mousemove', throttledShowMenu); document.addEventListener('mousemove', throttledShowMenu);
document.addEventListener('keydown', throttledShowMenu);
return () => { return () => {
document.removeEventListener('mousemove', throttledShowMenu); document.removeEventListener('mousemove', throttledShowMenu);
document.removeEventListener('keydown', throttledShowMenu);
if (fadeOut) { if (fadeOut) {
clearTimeout(fadeOut); clearTimeout(fadeOut);
} }
}; };
}, []); }, []);
return isMouseMoved; return isUserActive;
}; };
@@ -12,7 +12,7 @@ type noop = (this: any, ...args: any[]) => any;
type PickFunction<T extends noop> = (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnType<T>; type PickFunction<T extends noop> = (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnType<T>;
export const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function'; const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function';
export default function useMemoisedFn<T extends noop>(fn: T) { export default function useMemoisedFn<T extends noop>(fn: T) {
if (isDev) { if (isDev) {
+5 -11
View File
@@ -91,14 +91,6 @@ export const setPlayback = {
}, },
}; };
export const useInfoPanel = createSelector((state: RuntimeStore) => ({
eventNow: state.eventNow,
eventNext: state.eventNext,
playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents,
}));
export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current); export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current);
export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({ export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({
@@ -145,8 +137,6 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({
timeDanger: state.eventNow?.timeDanger ?? null, timeDanger: state.eventNow?.timeDanger ?? null,
})); }));
export const setClientName = (newName: string) => socketSendJson('set-client-name', newName);
export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({ export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({
plannedStart: state.runtime.plannedStart, plannedStart: state.runtime.plannedStart,
actualStart: state.runtime.actualStart, actualStart: state.runtime.actualStart,
@@ -174,11 +164,15 @@ export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset, offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
offsetMode: state.runtime.offsetMode, offsetMode: state.runtime.offsetMode,
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event currentDay: state.eventNow?.dayOffset ?? 0,
actualStart: state.runtime.actualStart, actualStart: state.runtime.actualStart,
plannedStart: state.runtime.plannedStart, plannedStart: state.runtime.plannedStart,
})); }));
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
currentDay: state.eventNow?.dayOffset ?? 0,
}));
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({ export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
offset: state.runtime.offset, offset: state.runtime.offset,
})); }));
+1 -1
View File
@@ -2,7 +2,7 @@ import { GetInfo } from 'ontime-types';
export const ontimePlaceholderInfo: GetInfo = { export const ontimePlaceholderInfo: GetInfo = {
networkInterfaces: [], networkInterfaces: [],
version: '2.0.0', version: '4.0.0',
serverPort: 4001, serverPort: 4001,
publicDir: '', publicDir: '',
}; };
@@ -1,8 +1,7 @@
import { Settings } from 'ontime-types'; import { Settings } from 'ontime-types';
export const ontimePlaceholderSettings: Settings = { export const ontimePlaceholderSettings: Settings = {
app: 'ontime', version: '4.0.0',
version: '2.0.0',
serverPort: 4001, serverPort: 4001,
editorKey: null, editorKey: null,
operatorKey: null, operatorKey: null,
@@ -8,4 +8,5 @@ export const projectDataPlaceholder: ProjectData = {
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null, projectLogo: null,
custom: [],
}; };
+11
View File
@@ -1,9 +1,20 @@
import { QueryClient } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import { isOntimeCloud } from '../externals';
export const ontimeQueryClient = new QueryClient({ export const ontimeQueryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
gcTime: 1000 * 60 * 10, // 10 min gcTime: 1000 * 60 * 10, // 10 min
}, },
mutations: {
/**
* React Query detects whether the client is online
* However, web access is not required for the clients when deployed locally
* - use 'always' for clients that may be online
* - use 'online' for clients that are connected to the cloud
*/
networkMode: isOntimeCloud ? 'online' : 'always',
},
}, },
}); });
+1 -1
View File
@@ -11,7 +11,7 @@ type LogStore = {
logs: Log[]; logs: Log[];
}; };
export const logger = createStore<LogStore>(() => ({ const logger = createStore<LogStore>(() => ({
logs: [], logs: [],
})); }));
-12
View File
@@ -14,18 +14,6 @@ export const runtimeStore = createWithEqualityFn<RuntimeStore>(
export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) => export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
useStoreWithEqualityFn(runtimeStore, selector, deepCompare); useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
let batchStore: Partial<RuntimeStore> = {};
export function addToBatchUpdates<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
batchStore[key] = value;
}
export function flushBatchUpdates() {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...batchStore });
batchStore = {};
}
/** /**
* Allows patching a property of the runtime store * Allows patching a property of the runtime store
*/ */
@@ -2,4 +2,4 @@
exports[`cx() > ignores falsy values 1`] = `""`; exports[`cx() > ignores falsy values 1`] = `""`;
exports[`cx() > merges styles 1`] = `"_test_98a1e0 _another_98a1e0"`; exports[`cx() > merges styles 1`] = `"_test_d5d33f _another_d5d33f"`;
@@ -1,12 +1,12 @@
import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../eventsManager'; import { cloneEvent } from '../clone';
describe('cloneEvent()', () => { describe('cloneEvent()', () => {
it('creates a stem from a given event', () => { it('creates a stem from a given event', () => {
const original: OntimeEvent = { const original: OntimeEvent = {
id: 'unique', id: 'unique',
type: SupportedEvent.Event, type: SupportedEntry.Event,
title: 'title', title: 'title',
cue: 'cue', cue: 'cue',
note: 'note', note: 'note',
@@ -15,7 +15,8 @@ describe('cloneEvent()', () => {
timeEnd: 10, timeEnd: 10,
timerType: TimerType.CountDown, timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: null, parent: 'test',
linkStart: false,
countToEnd: false, countToEnd: false,
endAction: EndAction.None, endAction: EndAction.None,
isPublic: false, isPublic: false,
@@ -27,17 +28,19 @@ describe('cloneEvent()', () => {
delay: 0, delay: 0,
dayOffset: 0, dayOffset: 0,
gap: 0, gap: 0,
triggers: [],
custom: { custom: {
lighting: '3', lighting: '3',
} as EventCustomFields, } as EntryCustomFields,
}; };
const cloned = cloneEvent(original); const cloned = cloneEvent(original);
expect(cloned).not.toBe(original); expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom); expect(cloned.custom).not.toBe(original.custom);
expect(cloned.triggers).not.toBe(original.triggers);
expect(cloned).toMatchObject({ expect(cloned).toMatchObject({
type: SupportedEvent.Event, type: SupportedEntry.Event,
title: original.title, title: original.title,
note: original.note, note: original.note,
timeStart: original.timeStart, timeStart: original.timeStart,
@@ -45,6 +48,7 @@ describe('cloneEvent()', () => {
timeEnd: original.timeEnd, timeEnd: original.timeEnd,
timerType: original.timerType, timerType: original.timerType,
timeStrategy: original.timeStrategy, timeStrategy: original.timeStrategy,
parent: 'test',
countToEnd: original.countToEnd, countToEnd: original.countToEnd,
linkStart: original.linkStart, linkStart: original.linkStart,
endAction: original.endAction, endAction: original.endAction,
@@ -57,6 +61,8 @@ describe('cloneEvent()', () => {
gap: 0, gap: 0,
timeWarning: original.timeWarning, timeWarning: original.timeWarning,
timeDanger: original.timeDanger, timeDanger: original.timeDanger,
triggers: original.triggers,
custom: original.custom,
}); });
}); });
}); });
@@ -1,4 +1,6 @@
import { makeCSVFromArrayOfArrays } from '../csv'; import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv';
describe('makeCSVFromArrayOfArrays()', () => { describe('makeCSVFromArrayOfArrays()', () => {
it('joins an array of arrays with commas and newlines', () => { it('joins an array of arrays with commas and newlines', () => {
@@ -11,3 +13,34 @@ after newline,after comma
`); `);
}); });
}); });
describe('aggregateRundowns()', () => {
it('flattens an object of rundowns into a single array', () => {
const rundowns = {
first: {
id: '',
title: '',
revision: 0,
order: ['1', '2'],
flatOrder: ['1', '2'],
entries: {
'1': { id: '1' } as OntimeEntry,
'2': { id: '2' } as OntimeEntry,
},
},
second: {
id: '',
title: '',
revision: 0,
order: ['3', '4'],
flatOrder: ['3', '4'],
entries: {
'3': { id: '3' } as OntimeEntry,
'4': { id: '4' } as OntimeEntry,
},
} as Rundown,
} as ProjectRundowns;
expect(aggregateRundowns(rundowns)).toStrictEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]);
});
});
@@ -64,13 +64,13 @@ describe('getRouteFromPreset()', () => {
describe('handle url sharing edge cases', () => { describe('handle url sharing edge cases', () => {
it('finds the correct preset when the url contains extra arguments', () => { it('finds the correct preset when the url contains extra arguments', () => {
const location = resolvePath('/demopage?locked=true&token=123'); const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy() expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy();
}) });
it('appends the feature params to the alias', () => { it('appends the feature params to the alias', () => {
const location = resolvePath('/demopage?locked=true&token=123'); const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123') expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
}) });
}); });
}); });
@@ -83,25 +83,26 @@ describe('generatePathFromPreset()', () => {
}); });
test('appends the feature params to the alias', () => { test('appends the feature params to the alias', () => {
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123'); expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe(
'timer?user=guest&alias=demopage&locked=true&token=123',
);
}); });
}); });
describe('arePathsEquivalent()', () => { describe('arePathsEquivalent()', () => {
it("checks whether the paths match", () => { it('checks whether the paths match', () => {
expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy(); expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy();
expect(arePathsEquivalent('timer', 'timer')).toBeTruthy(); expect(arePathsEquivalent('timer', 'timer')).toBeTruthy();
expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy(); expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy();
}) });
it("checks whether the params match", () => { it('checks whether the params match', () => {
expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy(); expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
}) });
it("considers edge cases for the url sharing feature", () => { it('considers edge cases for the url sharing feature', () => {
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy(); expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
}) });
}); });
@@ -1,4 +1,4 @@
import { OntimeEvent, SupportedEvent } from 'ontime-types'; import { OntimeEvent, SupportedEntry } from 'ontime-types';
/** /**
* @description Creates a safe duplicate of an event * @description Creates a safe duplicate of an event
@@ -9,7 +9,7 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>; type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => { export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return { return {
type: SupportedEvent.Event, type: SupportedEntry.Event,
title: event.title, title: event.title,
note: event.note, note: event.note,
timeStart: event.timeStart, timeStart: event.timeStart,
@@ -23,12 +23,14 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
isPublic: event.isPublic, isPublic: event.isPublic,
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
parent: event.parent,
revision: 0, revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset, dayOffset: event.dayOffset,
gap: 0, gap: 0,
timeWarning: event.timeWarning, timeWarning: event.timeWarning,
timeDanger: event.timeDanger, timeDanger: event.timeDanger,
custom: { ...event.custom }, triggers: structuredClone(event.triggers),
custom: structuredClone(event.custom),
}; };
}; };
+24 -3
View File
@@ -1,10 +1,31 @@
import { stringify } from 'csv-stringify/browser/esm/sync'; import { stringify } from 'csv-stringify/browser/esm/sync';
import { OntimeEntry, ProjectRundowns } from 'ontime-types';
/** /**
* @description Converts an array of arrays to a CSV file * Converts an array of arrays to a CSV file
* @param {string[][]} arrayOfArrays
* @return {string}
*/ */
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string { export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
return stringify(arrayOfArrays); return stringify(arrayOfArrays);
} }
/**
* Receives an object of rundowns, and flattens them into a single, linear rundown
* Used for CSV export
*/
export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] {
const rundownKeys = Object.keys(rundowns);
if (rundownKeys.length === 0) return [];
const flatRundown: OntimeEntry[] = [];
for (const key of rundownKeys) {
const { order, entries } = rundowns[key];
for (let i = 0; i < order.length; i++) {
const entryId = order[i];
const entry = entries[entryId];
flatRundown.push(entry);
}
}
return flatRundown;
}
+21 -77
View File
@@ -1,4 +1,4 @@
import { Log, RundownCached, RuntimeStore } from 'ontime-types'; import { Log, Rundown, RuntimeStore } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals'; import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants'; import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
@@ -14,13 +14,12 @@ import {
} from '../stores/clientStore'; } from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore'; import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime'; import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
export let websocket: WebSocket | null = null; let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null; let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000; const reconnectInterval = 1000;
export let shouldReconnect = true;
export let hasConnected = false; export let hasConnected = false;
export let reconnectAttempts = 0; export let reconnectAttempts = 0;
@@ -49,18 +48,17 @@ export const connectSocket = () => {
websocket.onclose = () => { websocket.onclose = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
if (shouldReconnect) { // we decide to allows reconnect
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) { if (reconnectAttempts > 2) {
setOnlineStatus(false); setOnlineStatus(false);
} }
console.warn('WebSocket: attempting reconnect'); console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) { if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1; reconnectAttempts += 1;
connectSocket(); connectSocket();
} }
}, reconnectInterval); }, reconnectInterval);
}
}; };
websocket.onerror = (error) => { websocket.onerror = (error) => {
@@ -143,59 +141,10 @@ export const connectSocket = () => {
updateDevTools(serverPayload); updateDevTools(serverPayload);
break; break;
} }
case 'ontime-clock': { case 'ontime-patch': {
addToBatchUpdates('clock', payload); const patch = payload as Partial<RuntimeStore>;
updateDevTools({ clock: payload }); patchRuntime(patch);
break; updateDevTools(patch);
}
case 'ontime-timer': {
addToBatchUpdates('timer', payload);
updateDevTools({ timer: payload });
break;
}
case 'ontime-onAir': {
addToBatchUpdates('onAir', payload);
updateDevTools({ onAir: payload });
break;
}
case 'ontime-message': {
addToBatchUpdates('message', payload);
updateDevTools({ message: payload });
break;
}
case 'ontime-runtime': {
addToBatchUpdates('runtime', payload);
updateDevTools({ runtime: payload });
break;
}
case 'ontime-eventNow': {
addToBatchUpdates('eventNow', payload);
updateDevTools({ eventNow: payload });
break;
}
case 'ontime-currentBlock': {
addToBatchUpdates('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': {
addToBatchUpdates('publicEventNow', payload);
updateDevTools({ publicEventNow: payload });
break;
}
case 'ontime-eventNext': {
addToBatchUpdates('eventNext', payload);
updateDevTools({ eventNext: payload });
break;
}
case 'ontime-publicEventNext': {
addToBatchUpdates('publicEventNext', payload);
updateDevTools({ publicEventNext: payload });
break;
}
case 'ontime-auxtimer1': {
addToBatchUpdates('auxtimer1', payload);
updateDevTools({ auxtimer1: payload });
break; break;
} }
case 'ontime-refetch': { case 'ontime-refetch': {
@@ -205,7 +154,7 @@ export const connectSocket = () => {
invalidateAllCaches(); invalidateAllCaches();
} else if (target === 'RUNDOWN') { } else if (target === 'RUNDOWN') {
const { revision } = payload; const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1; const currentRevision = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) { if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
@@ -215,8 +164,8 @@ export const connectSocket = () => {
} }
break; break;
} }
case 'ontime-flush': { default: {
flushBatchUpdates(); console.log('unknown WS message', type);
break; break;
} }
} }
@@ -226,11 +175,6 @@ export const connectSocket = () => {
}; };
}; };
export const disconnectSocket = () => {
shouldReconnect = false;
websocket?.close();
};
export const socketSend = (message: any) => { export const socketSend = (message: any) => {
if (websocket && websocket.readyState === WebSocket.OPEN) { if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(message); websocket.send(message);
+2 -2
View File
@@ -35,7 +35,7 @@ function getFormatFromParams() {
* Gets the format options from the applicaton settings * Gets the format options from the applicaton settings
* @returns a string equivalent to the format, ie: hh:mm:ss a or HH:mm:ss * @returns a string equivalent to the format, ie: hh:mm:ss a or HH:mm:ss
*/ */
export function getFormatFromSettings(): TimeFormat { function getFormatFromSettings(): TimeFormat {
const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS); const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS);
return settings?.timeFormat ?? '24'; return settings?.timeFormat ?? '24';
} }
@@ -119,7 +119,7 @@ export function formatDuration(duration: number, hideSeconds = true): string {
} }
if (!hideSeconds) { if (!hideSeconds) {
const seconds = Math.floor((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND); const seconds = Math.ceil((duration % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND);
if (seconds > 0) { if (seconds > 0) {
result += `${seconds}s`; result += `${seconds}s`;
} }
-11
View File
@@ -1,11 +0,0 @@
import { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
import 'vitest';
// ugly hack because vite and pnpm are not playing ball with jest
// https://github.com/testing-library/jest-dom/issues/123
declare global {
namespace Vi {
type Assertion<T = any> = TestingLibraryMatchers<T, void>;
}
}
+1 -4
View File
@@ -49,10 +49,7 @@ function resolveUrl(protocol: 'http' | 'ws', path: string) {
url.pathname = baseURI ? `${baseURI}/${path}` : path; url.pathname = baseURI ? `${baseURI}/${path}` : path;
// in development mode, we use the React port for UI, but need the requests to target the server // in development mode, we use the React port for UI, but need the requests to target the server
if (isDev) { // this is done with a proxy in the vite config to avoid CORS issues in the dev environment
// this is used as a fallback port for development
url.port = '4001';
}
const result = url.toString(); const result = url.toString();
@@ -29,8 +29,8 @@ export default function ReportSettings() {
}; };
const combinedReport = useMemo(() => { const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.rundown, data.order); return getCombinedReport(reportData, data.entries, data.order);
}, [reportData, data.rundown, data.order]); }, [reportData, data.entries, data.order]);
return ( return (
<Panel.Section> <Panel.Section>
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoPencil, IoTrash } from 'react-icons/io5'; import { IoPencil, IoTrash } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react'; import { IconButton } from '@chakra-ui/react';
import { CustomField, CustomFieldLabel } from 'ontime-types'; import { CustomField, CustomFieldKey } from 'ontime-types';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag'; import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch'; import Swatch from '../../../../../common/components/input/colour-input/Swatch';
@@ -17,8 +17,8 @@ interface CustomFieldEntryProps {
label: string; label: string;
fieldKey: string; fieldKey: string;
type: 'string' | 'image'; type: 'string' | 'image';
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>; onEdit: (key: CustomFieldKey, patch: CustomField) => Promise<void>;
onDelete: (label: CustomFieldLabel) => Promise<void>; onDelete: (key: CustomFieldKey) => Promise<void>;
} }
export default function CustomFieldEntry(props: CustomFieldEntryProps) { export default function CustomFieldEntry(props: CustomFieldEntryProps) {
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { CustomField, CustomFieldLabel } from 'ontime-types'; import { CustomField, CustomFieldKey } from 'ontime-types';
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields'; import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
import Info from '../../../../../common/components/info/Info'; import Info from '../../../../../common/components/info/Info';
@@ -31,14 +31,14 @@ export default function CustomFields() {
setIsAdding(false); setIsAdding(false);
}; };
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => { const handleEditField = async (key: CustomFieldKey, customField: CustomField) => {
await editCustomField(label, customField); await editCustomField(key, customField);
refetch(); refetch();
}; };
const handleDelete = async (label: string) => { const handleDelete = async (key: CustomFieldKey) => {
try { try {
await deleteCustomField(label); await deleteCustomField(key);
refetch(); refetch();
} catch (_error) { } catch (_error) {
/** we do not handle errors here */ /** we do not handle errors here */
@@ -1,4 +1,4 @@
import { isOntimeEvent, MaybeNumber, NormalisedRundown, OntimeReport } from 'ontime-types'; import { EntryId, isOntimeEvent, MaybeNumber, OntimeReport, RundownEntries } from 'ontime-types';
import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatTime } from '../../../../common/utils/time'; import { formatTime } from '../../../../common/utils/time';
@@ -16,7 +16,7 @@ export type CombinedReport = {
/** /**
* Creates a combined report with the rundown data * Creates a combined report with the rundown data
*/ */
export function getCombinedReport(report: OntimeReport, rundown: NormalisedRundown, order: string[]): CombinedReport[] { export function getCombinedReport(report: OntimeReport, rundown: RundownEntries, order: EntryId[]): CombinedReport[] {
if (Object.keys(report).length === 0) return []; if (Object.keys(report).length === 0) return [];
if (order.length === 0) return []; if (order.length === 0) return [];
@@ -13,10 +13,6 @@ import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './GeneralPinInput'; import GeneralPinInput from './GeneralPinInput';
export type GeneralPanelFormValues = {
filename: string;
};
export default function GeneralPanelForm() { export default function GeneralPanelForm() {
const { data, status, refetch } = useSettings(); const { data, status, refetch } = useSettings();
const { const {
@@ -8,7 +8,5 @@
} }
.column { .column {
align-items: start;
display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -123,7 +123,7 @@ export default function ViewSettingsForm() {
onClick={onCodeEditorOpen} onClick={onCodeEditorOpen}
variant='ontime-subtle' variant='ontime-subtle'
size='sm' size='sm'
isDisabled={!data.overrideStyles} isDisabled={isSubmitting}
width='fit-content' width='fit-content'
> >
Edit CSS override Edit CSS override
@@ -102,7 +102,6 @@ export default function EditorSettingsForm() {
onChange={(event) => setDefaultEndAction(event.target.value as EndAction)} onChange={(event) => setDefaultEndAction(event.target.value as EndAction)}
> >
<option value={EndAction.None}>None</option> <option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load next</option> <option value={EndAction.LoadNext}>Load next</option>
<option value={EndAction.PlayNext}>Play next</option> <option value={EndAction.PlayNext}>Play next</option>
</Select> </Select>
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@@ -23,6 +24,7 @@ type ProjectCreateFormValues = {
publicUrl?: string; publicUrl?: string;
backstageInfo?: string; backstageInfo?: string;
backstageUrl?: string; backstageUrl?: string;
custom?: { title: string; value: string }[];
}; };
export default function ProjectCreateForm(props: ProjectCreateFromProps) { export default function ProjectCreateForm(props: ProjectCreateFromProps) {
@@ -34,6 +36,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const { const {
handleSubmit, handleSubmit,
register, register,
control,
formState: { isSubmitting, isValid }, formState: { isSubmitting, isValid },
setFocus, setFocus,
} = useForm<ProjectCreateFormValues>({ } = useForm<ProjectCreateFormValues>({
@@ -44,6 +47,11 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
}, },
}); });
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
});
// set focus to first field // set focus to first field
useEffect(() => { useEffect(() => {
setFocus('title'); setFocus('title');
@@ -59,6 +67,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
...values, ...values,
filename, filename,
}); });
await queryClient.invalidateQueries({ queryKey: PROJECT_LIST }); await queryClient.invalidateQueries({ queryKey: PROJECT_LIST });
onClose(); onClose();
} catch (error) { } catch (error) {
@@ -66,6 +75,10 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
} }
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
return ( return (
<Panel.Section <Panel.Section
as='form' as='form'
@@ -151,6 +164,42 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
<Panel.Section>
<Panel.ListItem>
<Panel.Field title='Custom data' description='Add custom data for your project' />
<Button variant='ontime-subtle' onClick={handleAddCustom}>
+
</Button>
</Panel.ListItem>
{fields.map((field, idx) => (
<div key={field.id} className={style.customDataItem}>
<Panel.Paragraph>{idx + 1}.</Panel.Paragraph>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
placeholder={field.title}
autoComplete='off'
{...register(`custom.${idx}.title` as const)}
/>
</label>
<label>
Value
<Input
variant='ontime-filled'
size='sm'
placeholder={field.value}
autoComplete='off'
{...register(`custom.${idx}.value` as const)}
/>
</label>
<Button variant='ontime-ghosted' onClick={() => remove(idx)}>
<IoTrash />
</Button>
</div>
))}
</Panel.Section>
</Panel.Section> </Panel.Section>
</Panel.Section> </Panel.Section>
); );
@@ -1,6 +1,6 @@
import { ChangeEvent, useEffect, useRef } from 'react'; import { ChangeEvent, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoDownloadOutline, IoTrash } from 'react-icons/io5'; import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5';
import { Button, Input, Textarea } from '@chakra-ui/react'; import { Button, Input, Textarea } from '@chakra-ui/react';
import { type ProjectData } from 'ontime-types'; import { type ProjectData } from 'ontime-types';
@@ -25,6 +25,7 @@ export default function ProjectData() {
formState: { isSubmitting, isValid, isDirty, errors }, formState: { isSubmitting, isValid, isDirty, errors },
setError, setError,
watch, watch,
control,
setValue, setValue,
} = useForm({ } = useForm({
defaultValues: data, defaultValues: data,
@@ -32,6 +33,12 @@ export default function ProjectData() {
resetOptions: { resetOptions: {
keepDirtyValues: true, keepDirtyValues: true,
}, },
mode: 'onChange',
});
const { fields, append, remove } = useFieldArray({
control,
name: 'custom',
}); });
// reset form values if data changes // reset form values if data changes
@@ -77,6 +84,10 @@ export default function ProjectData() {
}); });
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
const onSubmit = async (formData: ProjectData) => { const onSubmit = async (formData: ProjectData) => {
try { try {
await postProjectData(formData); await postProjectData(formData);
@@ -231,6 +242,69 @@ export default function ProjectData() {
{...register('backstageUrl')} {...register('backstageUrl')}
/> />
</label> </label>
<Panel.Section style={{ marginTop: 0 }}>
<Panel.ListItem>
<Panel.Field title='Custom data' description='' />
<Button leftIcon={<IoAdd />} size='sm' variant='ontime-subtle' onClick={handleAddCustom}>
Add
</Button>
</Panel.ListItem>
{fields.length > 0 &&
fields.map((field, idx) => {
const rowErrors = errors.custom?.[idx] as
| {
title?: { message?: string };
value?: { message?: string };
}
| undefined;
return (
<div key={field.id} className={style.customDataItem}>
<div>
<div className={style.titleRow}>
<label>
Title
<Input
variant='ontime-filled'
size='sm'
defaultValue={field.title}
placeholder='Title of your custom data'
autoComplete='off'
{...register(`custom.${idx}.title`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
</label>
<Button
size='sm'
variant='ontime-subtle'
color='#FA5656' // $red-500
onClick={() => remove(idx)}
leftIcon={<IoTrash />}
>
Delete Entry
</Button>
</div>
{rowErrors?.title?.message && <Panel.Error>{rowErrors.title.message}</Panel.Error>}
</div>
<label>
Value
<Textarea
variant='ontime-filled'
resize='none'
size='sm'
defaultValue={field.value}
autoComplete='off'
placeholder='Text of your custom data'
{...register(`custom.${idx}.value`, {
required: { value: true, message: 'Field cannot be empty' },
})}
/>
{rowErrors?.value?.message && <Panel.Error>{rowErrors.value.message}</Panel.Error>}
</label>
</div>
);
})}
</Panel.Section>
</Panel.Section> </Panel.Section>
</Panel.Card> </Panel.Card>
</Panel.Section> </Panel.Section>
@@ -57,3 +57,18 @@
height: auto; height: auto;
} }
} }
.customDataItem {
display: contents;
width: 100%;
.titleRow{
display: flex;
gap: 1rem;
align-items: end;
label {
flex: 1;
}
}
}
@@ -6,7 +6,7 @@ export async function makeProjectPatch(data: DatabaseModel, mergeKeys: Record<st
for (const key in mergeKeys) { for (const key in mergeKeys) {
if (isKeyOfType(key, data) && mergeKeys[key]) { if (isKeyOfType(key, data) && mergeKeys[key]) {
// if the rundown is merged we also need the custom fields // if the rundown is merged we also need the custom fields
if (key === 'rundown') { if (key === 'rundowns') {
patchObject.customFields = data['customFields']; patchObject.customFields = data['customFields'];
} }
Object.assign(patchObject, { [key]: data[key] }); Object.assign(patchObject, { [key]: data[key] });
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { CustomFields, OntimeRundown } from 'ontime-types'; import { CustomFields, Rundown } from 'ontime-types';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -9,7 +9,7 @@ import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore'; import { useSheetStore } from './useSheetStore';
interface ImportReviewProps { interface ImportReviewProps {
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
onFinished: () => void; onFinished: () => void;
onCancel: () => void; onCancel: () => void;
@@ -29,7 +29,12 @@ export default function ImportReview(props: ImportReviewProps) {
const applyImport = async () => { const applyImport = async () => {
setLoading(true); setLoading(true);
await importRundown(rundown, customFields); await importRundown(
{
[rundown.id]: rundown,
},
customFields,
);
setLoading(false); setLoading(false);
onFinished(); onFinished();
}; };
@@ -103,6 +103,7 @@ export default function SourcesPanel() {
}; };
const handleSubmitImportPreview = async (importMap: ImportMap) => { const handleSubmitImportPreview = async (importMap: ImportMap) => {
setError(''); // to clear previous error
if (importFlow === 'excel') { if (importFlow === 'excel') {
try { try {
const previewData = await importRundownPreviewExcel(importMap); const previewData = await importRundownPreviewExcel(importMap);
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5'; import { IoAdd, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react'; import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
@@ -28,6 +28,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
control, control,
handleSubmit, handleSubmit,
register, register,
setValue,
formState: { errors, isValid }, formState: { errors, isValid },
} = useForm<NamedImportMap>({ } = useForm<NamedImportMap>({
mode: 'onChange', mode: 'onChange',
@@ -45,6 +46,14 @@ export default function ImportMapForm(props: ImportMapFormProps) {
const [loading, setLoading] = useState<'' | 'export' | 'import'>(''); const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
// Set first sheet as default worksheet when 'event schedule' sheet is not there
useEffect(() => {
if (!worksheetNames || worksheetNames.length === 0) return;
if (!worksheetNames.includes(namedImportMap.Worksheet)) {
setValue('Worksheet', worksheetNames[0], { shouldValidate: true, shouldDirty: true });
}
}, [worksheetNames, setValue, namedImportMap.Worksheet]);
const handleExport = async (values: NamedImportMap) => { const handleExport = async (values: NamedImportMap) => {
setLoading('export'); setLoading('export');
const importMap = convertToImportMap(values); const importMap = convertToImportMap(values);
@@ -3,7 +3,7 @@ import { ImportCustom, ImportMap } from 'ontime-utils';
export type NamedImportMap = typeof namedImportMap; export type NamedImportMap = typeof namedImportMap;
// Record of label and import name // Record of label and import name
export const namedImportMap = { const namedImportMap = {
Worksheet: 'event schedule', Worksheet: 'event schedule',
Start: 'time start', Start: 'time start',
'Link start': 'link start', 'Link start': 'link start',
@@ -1,6 +1,6 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { IoLink } from 'react-icons/io5'; import { IoLink } from 'react-icons/io5';
import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types'; import { CustomFields, isOntimeBlock, isOntimeEvent, Rundown } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import Tag from '../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
@@ -10,7 +10,7 @@ import * as Panel from '../../../panel-utils/PanelUtils';
import style from './PreviewRundown.module.scss'; import style from './PreviewRundown.module.scss';
interface PreviewRundownProps { interface PreviewRundownProps {
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
} }
@@ -53,75 +53,76 @@ export default function PreviewRundown(props: PreviewRundownProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rundown.map((event) => { {rundown.order.map((entryId) => {
if (isOntimeBlock(event)) { const entry = rundown.entries[entryId];
if (isOntimeBlock(entry)) {
return ( return (
<tr key={event.id}> <tr key={entry.id}>
<td className={style.center}> <td className={style.center}>
<Tag>-</Tag> <Tag>-</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.type}</Tag> <Tag>{entry.type}</Tag>
</td> </td>
<td /> <td />
<td colSpan={99}>{event.title}</td> <td colSpan={99}>{entry.title}</td>
</tr> </tr>
); );
} }
if (!isOntimeEvent(event)) { if (!isOntimeEvent(entry)) {
return null; return null;
} }
eventIndex += 1; eventIndex += 1;
const colour = event.colour ? getAccessibleColour(event.colour) : {}; const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
const countToEnd = booleanToText(event.countToEnd); const countToEnd = booleanToText(entry.countToEnd);
const isPublic = booleanToText(event.isPublic); const isPublic = booleanToText(entry.isPublic);
const skip = booleanToText(event.skip); const skip = booleanToText(entry.skip);
return ( return (
<Fragment key={event.id}> <Fragment key={entry.id}>
<tr> <tr>
<td className={style.center}> <td className={style.center}>
<Tag>{eventIndex}</Tag> <Tag>{eventIndex}</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.type}</Tag> <Tag>{entry.type}</Tag>
</td> </td>
<td className={style.nowrap}>{event.cue}</td> <td className={style.nowrap}>{entry.cue}</td>
<td>{event.title}</td> <td>{entry.title}</td>
<td className={style.flex}> <td className={style.flex}>
<span className={event.linkStart ? style.subdued : undefined}>{millisToString(event.timeStart)}</span> <span className={entry.linkStart ? style.subdued : undefined}>{millisToString(entry.timeStart)}</span>
{event.linkStart && <IoLink className={style.linkStartActive} />} {entry.linkStart && <IoLink className={style.linkStartActive} />}
</td> </td>
<td>{millisToString(event.timeEnd)}</td> <td>{millisToString(entry.timeEnd)}</td>
<td>{millisToString(event.duration)}</td> <td>{millisToString(entry.duration)}</td>
<td>{millisToString(event.timeWarning)}</td> <td>{millisToString(entry.timeWarning)}</td>
<td>{millisToString(event.timeDanger)}</td> <td>{millisToString(entry.timeDanger)}</td>
<td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td> <td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td>
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td> <td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td> <td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{event.colour}</td> <td style={{ ...colour }}>{entry.colour}</td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.timerType}</Tag> <Tag>{entry.timerType}</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.endAction}</Tag> <Tag>{entry.endAction}</Tag>
</td> </td>
{isOntimeEvent(event) && {isOntimeEvent(entry) &&
fieldKeys.map((field) => { fieldKeys.map((field) => {
let value = ''; let value = '';
if (field in event.custom) { if (field in entry.custom) {
value = event.custom[field]; value = entry.custom[field];
} }
return <td key={field}>{value}</td>; return <td key={field}>{value}</td>;
})} })}
<td className={style.center}> <td className={style.center}>
<Tag>{event.id}</Tag> <Tag>{entry.id}</Tag>
</td> </td>
</tr> </tr>
{event.note && ( {entry.note && (
<tr> <tr>
<td colSpan={99} className={style.secondaryRow}> <td colSpan={99} className={style.secondaryRow}>
Note: {event.note} Note: {entry.note}
</td> </td>
</tr> </tr>
)} )}
@@ -1,5 +1,5 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, ProjectRundowns } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants'; import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants';
@@ -75,9 +75,9 @@ export default function useGoogleSheet() {
}; };
/** applies rundown and customFields to current project */ /** applies rundown and customFields to current project */
const importRundown = async (rundown: OntimeRundown, customFields: CustomFields) => { const importRundown = async (rundowns: ProjectRundowns, customFields: CustomFields) => {
try { try {
await patchData({ rundown, customFields }); await patchData({ rundowns, customFields });
// we are unable to optimistically set the rundown since we need // we are unable to optimistically set the rundown since we need
// it to be normalised // it to be normalised
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
@@ -1,4 +1,4 @@
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils'; import { defaultImportMap, ImportMap } from 'ontime-utils';
import { create } from 'zustand'; import { create } from 'zustand';
@@ -15,8 +15,8 @@ type SheetStore = {
setAuthenticationStatus: (status: AuthenticationStatus) => void; setAuthenticationStatus: (status: AuthenticationStatus) => void;
// we get this from a preview response // we get this from a preview response
rundown: OntimeRundown | null; rundown: Rundown | null;
setRundown: (rundown: OntimeRundown | null) => void; setRundown: (rundown: Rundown | null) => void;
// we get this from a preview response // we get this from a preview response
customFields: CustomFields | null; customFields: CustomFields | null;
@@ -60,7 +60,7 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }), setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }),
setRundown: (rundown: OntimeRundown | null) => set({ rundown }), setRundown: (rundown: Rundown | null) => set({ rundown }),
setCustomFields: (customFields: CustomFields | null) => set({ customFields }), setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
@@ -2,7 +2,7 @@ import { IoAdd, IoRemove } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { Playback } from 'ontime-types'; import { Playback } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils'; import { MILLIS_PER_HOUR, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { setPlayback } from '../../../../common/hooks/useSocket'; import { setPlayback } from '../../../../common/hooks/useSocket';
@@ -17,29 +17,29 @@ interface AddTimeProps {
export default function AddTime(props: AddTimeProps) { export default function AddTime(props: AddTimeProps) {
const { playback } = props; const { playback } = props;
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes const [timeInMs, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
const handleTimeChange = (_field: string, value: string) => { const handleTimeChange = (_field: string, value: string) => {
const newTime = parseUserTime(value); const newTimeInMs = parseUserTime(value);
// cap add time to 1 hour // cap add time to 1 hour
setTime(Math.min(newTime, MILLIS_PER_HOUR)); setTime(Math.min(newTimeInMs, MILLIS_PER_HOUR));
}; };
const handleAddTime = (direction: 'add' | 'remove') => { const handleAddTime = (direction: 'add' | 'remove') => {
// API expects input in seconds // API expects input in milliseconds
if (direction === 'add') { if (direction === 'add') {
setPlayback.addTime(time / MILLIS_PER_SECOND); setPlayback.addTime(timeInMs);
} else { } else {
setPlayback.addTime((-1 * time) / MILLIS_PER_SECOND); setPlayback.addTime(-1 * timeInMs);
} }
}; };
const canAddTime = playback === Playback.Play || playback === Playback.Pause; const canAddTime = playback === Playback.Play || playback === Playback.Pause;
const doDisableButtons = !canAddTime || time === 0; const doDisableButtons = !canAddTime || timeInMs === 0;
return ( return (
<div className={style.addTime}> <div className={style.addTime}>
<TimeInput name='addtime' submitHandler={handleTimeChange} time={time} placeholder='Add time' /> <TimeInput name='addtime' submitHandler={handleTimeChange} time={timeInMs} placeholder='Add time' />
<div className={style.addButtons}> <div className={style.addButtons}>
<Tooltip label='Remove time' openDelay={tooltipDelayMid} shouldWrapChildren> <Tooltip label='Remove time' openDelay={tooltipDelayMid} shouldWrapChildren>
<TapButton onClick={() => handleAddTime('remove')} disabled={doDisableButtons} className={style.tallButtons}> <TapButton onClick={() => handleAddTime('remove')} disabled={doDisableButtons} className={style.tallButtons}>
@@ -59,15 +59,20 @@ export function AuxTimer() {
} }
function AuxTimerInput() { function AuxTimerInput() {
const time = useAuxTimerTime(); const newTimeInMs = useAuxTimerTime();
const { setDuration } = setAuxTimer; const { setDuration } = setAuxTimer;
const handleTimeUpdate = (_field: string, value: string) => { const handleTimeUpdate = (_field: string, value: string) => {
const newTime = parseUserTime(value); const newTimeInMs = parseUserTime(value);
setDuration(newTime / 1000); // frontend api is seconds based setDuration(newTimeInMs);
}; };
return ( return (
<TimeInput<'auxTimer'> submitHandler={handleTimeUpdate} name='auxTimer' time={time} placeholder='Aux Timer 1' /> <TimeInput<'auxTimer'>
submitHandler={handleTimeUpdate}
name='auxTimer'
time={newTimeInMs}
placeholder='Aux Timer 1'
/>
); );
} }
+1 -8
View File
@@ -10,7 +10,6 @@ import AppSettings from '../app-settings/AppSettings';
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation'; import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
import { EditorOverview } from '../overview/Overview'; import { EditorOverview } from '../overview/Overview';
import Finder from './finder/Finder';
import WelcomePlacement from './welcome/WelcomePlacement'; import WelcomePlacement from './welcome/WelcomePlacement';
import styles from './Editor.module.scss'; import styles from './Editor.module.scss';
@@ -22,7 +21,6 @@ const MessageControl = lazy(() => import('../control/message/MessageControlExpor
export default function Editor() { export default function Editor() {
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation(); const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure(); const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isFinderOpen, onToggle: onFinderToggle, onClose: onFinderClose } = useDisclosure();
useWindowTitle('Editor'); useWindowTitle('Editor');
@@ -46,16 +44,11 @@ export default function Editor() {
} }
}, [close, isSettingsOpen, setLocation]); }, [close, isSettingsOpen, setLocation]);
useHotkeys([ useHotkeys([['mod + ,', toggleSettings]]);
['mod + ,', toggleSettings],
['mod + f', onFinderToggle],
['Escape', onFinderClose],
]);
return ( return (
<div className={styles.mainContainer} data-testid='event-editor'> <div className={styles.mainContainer} data-testid='event-editor'>
<WelcomePlacement /> <WelcomePlacement />
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
<NavigationMenu isOpen={isMenuOpen} onClose={onClose} /> <NavigationMenu isOpen={isMenuOpen} onClose={onClose} />
<EditorOverview> <EditorOverview>
<IconButton <IconButton
@@ -1,7 +1,7 @@
import { KeyboardEvent, useState } from 'react'; import { KeyboardEvent, useState } from 'react';
import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react'; import { Input, Modal, ModalBody, ModalContent, ModalFooter, ModalOverlay } from '@chakra-ui/react';
import { useDebouncedCallback } from '@mantine/hooks'; import { useDebouncedCallback } from '@mantine/hooks';
import { SupportedEvent } from 'ontime-types'; import { SupportedEntry } from 'ontime-types';
import { useEventSelection } from '../../rundown/useEventSelection'; import { useEventSelection } from '../../rundown/useEventSelection';
@@ -67,9 +67,9 @@ export default function Finder(props: FinderProps) {
{results.length > 0 && {results.length > 0 &&
results.map((entry, index) => { results.map((entry, index) => {
const isSelected = selected === index; const isSelected = selected === index;
const displayIndex = entry.type === SupportedEvent.Event ? entry.eventIndex : '-'; const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-';
const displayCue = entry.type === SupportedEvent.Event ? entry.cue : ''; const displayCue = entry.type === SupportedEntry.Event ? entry.cue : '';
const colour = entry.type === SupportedEvent.Event ? entry.colour : ''; const colour = entry.type === SupportedEntry.Event ? entry.colour : '';
return ( return (
<li <li
@@ -1,19 +1,19 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react'; import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEvent } from 'ontime-types'; import { isOntimeBlock, isOntimeEvent, MaybeString, SupportedEntry } from 'ontime-types';
import { useFlatRundown } from '../../../common/hooks-query/useRundown'; import { useFlatRundown } from '../../../common/hooks-query/useRundown';
const maxResults = 12; const maxResults = 12;
type FilterableBlock = { type FilterableBlock = {
type: SupportedEvent.Block; type: SupportedEntry.Block;
id: string; id: string;
index: number; index: number;
title: string; title: string;
}; };
type FilterableEvent = { type FilterableEvent = {
type: SupportedEvent.Event; type: SupportedEntry.Event;
id: string; id: string;
index: number; index: number;
eventIndex: number; eventIndex: number;
@@ -59,7 +59,7 @@ export default function useFinder() {
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) { if (eventIndex === searchIndex) {
results.push({ results.push({
type: SupportedEvent.Event, type: SupportedEntry.Event,
id: event.id, id: event.id,
index: i, index: i,
eventIndex, eventIndex,
@@ -93,7 +93,7 @@ export default function useFinder() {
if (event.cue.toLowerCase().includes(searchString)) { if (event.cue.toLowerCase().includes(searchString)) {
remaining--; remaining--;
results.push({ results.push({
type: SupportedEvent.Event, type: SupportedEntry.Event,
id: event.id, id: event.id,
index: i, index: i,
eventIndex, eventIndex,
@@ -126,7 +126,7 @@ export default function useFinder() {
if (event.title.toLowerCase().includes(searchString)) { if (event.title.toLowerCase().includes(searchString)) {
remaining--; remaining--;
results.push({ results.push({
type: SupportedEvent.Event, type: SupportedEntry.Event,
id: event.id, id: event.id,
index: i, index: i,
eventIndex, eventIndex,
@@ -141,7 +141,7 @@ export default function useFinder() {
if (event.title.toLowerCase().includes(searchString)) { if (event.title.toLowerCase().includes(searchString)) {
remaining--; remaining--;
results.push({ results.push({
type: SupportedEvent.Block, type: SupportedEntry.Block,
id: event.id, id: event.id,
index: i, index: i,
title: event.title, title: event.title,
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types'; import { isOntimeEvent, OntimeEvent, SupportedEntry } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils'; import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
@@ -126,8 +126,8 @@ export default function Operator() {
let isPast = Boolean(featureData.selectedEventId); let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast')); const hidePast = isStringBoolean(searchParams.get('hidepast'));
const { firstEvent } = getFirstEventNormal(data.rundown, data.order); const { firstEvent } = getFirstEventNormal(data.entries, data.order);
const { lastEvent } = getLastEventNormal(data.rundown, data.order); const { lastEvent } = getLastEventNormal(data.entries, data.order);
return ( return (
<div className={style.operatorContainer}> <div className={style.operatorContainer}>
@@ -152,7 +152,7 @@ export default function Operator() {
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{data.order.map((eventId) => { {data.order.map((eventId) => {
const entry = data.rundown[eventId]; const entry = data.entries[eventId];
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
const isSelected = featureData.selectedEventId === entry.id; const isSelected = featureData.selectedEventId === entry.id;
if (isSelected) { if (isSelected) {
@@ -197,7 +197,7 @@ export default function Operator() {
); );
} }
if (entry.type === SupportedEvent.Block) { if (entry.type === SupportedEntry.Block) {
return <OperatorBlock key={entry.id} title={entry.title} />; return <OperatorBlock key={entry.id} title={entry.title} />;
} }
return null; return null;
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
import { Button, Textarea } from '@chakra-ui/react'; import { Button, Textarea } from '@chakra-ui/react';
import { OntimeEvent } from 'ontime-types'; import { OntimeEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import type { EditEvent } from '../Operator'; import type { EditEvent } from '../Operator';
import style from './EditModal.module.scss'; import style from './EditModal.module.scss';
@@ -15,7 +15,7 @@ interface EditModalProps {
export default function EditModal(props: EditModalProps) { export default function EditModal(props: EditModalProps) {
const { event, onClose } = props; const { event, onClose } = props;
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLTextAreaElement[]>(new Array<HTMLTextAreaElement>()); const inputRef = useRef<HTMLTextAreaElement[]>(new Array<HTMLTextAreaElement>());
@@ -36,7 +36,7 @@ export default function EditModal(props: EditModalProps) {
}); });
if (patchObject.custom) { if (patchObject.custom) {
await updateEvent(patchObject); await updateEntry(patchObject);
} }
setLoading(false); setLoading(false);
@@ -118,7 +118,8 @@
padding-inline: 0.25rem; padding-inline: 0.25rem;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
white-space: pre; // allow multi-line text but trim before
white-space: pre-line;
} }
} }
@@ -78,7 +78,7 @@ function OperatorEvent(props: OperatorEventProps) {
<span className={style.mainField}>{main}</span> <span className={style.mainField}>{main}</span>
<span className={style.schedule}> <span className={style.schedule}>
<ClockTime value={timeStart} preferredFormat12='h:mm' preferredFormat24='HH:mm' /> <ClockTime value={timeStart} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
-
<ClockTime value={timeEnd} preferredFormat12='h:mm' preferredFormat24='HH:mm' /> <ClockTime value={timeEnd} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
</span> </span>
@@ -36,8 +36,8 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
}, },
{ {
id: 'subscribe', id: 'subscribe',
title: 'Highlight Field', title: 'Highlight Fields',
description: 'Choose a custom field to highlight', description: 'Choose custom fields to highlight',
type: 'multi-option', type: 'multi-option',
values: customFieldSelect, values: customFieldSelect,
}, },
@@ -45,6 +45,10 @@
@include ellipsis-overflow; @include ellipsis-overflow;
} }
.offset {
color: $muted-gray;
}
.ahead { .ahead {
color: $playback-ahead; color: $playback-ahead;
} }
@@ -1,4 +1,5 @@
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react'; import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
@@ -163,10 +164,10 @@ function ProgressOverview() {
} }
function RuntimeOverview() { function RuntimeOverview() {
const { clock, offset } = useRuntimePlaybackOverview(); const { clock, offset, playback } = useRuntimePlaybackOverview();
const offsetText = getOffsetText(offset); const offsetText = getOffsetText(offset);
const offsetClasses = offset === null ? undefined : offset <= 0 ? style.behind : style.ahead; const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]);
return ( return (
<> <>
@@ -0,0 +1,11 @@
.empty {
padding-block: 1.5rem;
text-align: center;
}
.inline {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
}
@@ -7,7 +7,6 @@
margin-top: 1rem; margin-top: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-right: 4px; // size of scrollbar
overflow-y: scroll; overflow-y: scroll;
height: 100%; height: 100%;
} }
@@ -16,6 +15,7 @@
overflow-x: clip; overflow-x: clip;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-inline: 1rem;
} }
.empty { .empty {
@@ -23,15 +23,6 @@
align-self: center; align-self: center;
} }
.alignCenter {
text-align: center;
flex-direction: column;
.spaceTop {
margin-top: 1.5rem;
}
}
.spacer { .spacer {
min-height: 50vh; min-height: 50vh;
} }
@@ -40,6 +31,7 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
} }
.entryIndex { .entryIndex {
+288 -146
View File
@@ -1,36 +1,48 @@
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'; import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys } from '@mantine/hooks';
import { import {
closestCenter,
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys, useSessionStorage } from '@mantine/hooks';
import {
type EntryId,
type MaybeString,
type Rundown,
isOntimeBlock, isOntimeBlock,
isOntimeEvent, isOntimeEvent,
isPlayableEvent, OntimeBlock,
MaybeString, OntimeEntry,
PlayableEvent,
Playback, Playback,
RundownCached, SupportedEntry,
SupportedEvent,
} from 'ontime-types'; } from 'ontime-types';
import { import {
checkIsNextDay,
getFirstNormal, getFirstNormal,
getLastNormal, getLastNormal,
getNextBlockNormal, getNextBlockNormal,
getNextNormal, getNextNormal,
getPreviousBlockNormal, getPreviousBlockNormal,
getPreviousNormal, getPreviousNormal,
isNewLatest, reorderArray,
} from 'ontime-utils'; } from 'ontime-utils';
import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction'; import { type EventOptions, useEntryActions } from '../../common/hooks/useEntryAction';
import useFollowComponent from '../../common/hooks/useFollowComponent'; import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore'; import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/eventsManager'; import { cloneEvent } from '../../common/utils/clone';
import BlockBlock from './block-block/BlockBlock';
import BlockEnd from './block-block/BlockEnd';
import QuickAddBlock from './quick-add-block/QuickAddBlock'; import QuickAddBlock from './quick-add-block/QuickAddBlock';
import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from './rundown.utils';
import RundownEmpty from './RundownEmpty'; import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
@@ -39,15 +51,21 @@ import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry')); const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps { interface RundownProps {
data: RundownCached; data: Rundown;
} }
export default function Rundown({ data }: RundownProps) { export default function Rundown({ data }: RundownProps) {
const { order, rundown } = data; const { order, entries, id } = data;
const [statefulEntries, setStatefulEntries] = useState(order); // we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const { addEvent, reorderEvent, deleteEvent } = useEventAction(); const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
// we ensure that this is unique to the rundown
key: `rundown.${id}-editor-collapsed-groups`,
defaultValue: [],
});
const { addEntry, reorderEntry, deleteEntry } = useEntryActions();
const { entryCopyId, setEntryCopyId } = useEntryCopy(); const { entryCopyId, setEntryCopyId } = useEntryCopy();
@@ -60,39 +78,39 @@ export default function Rundown({ data }: RundownProps) {
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run }); useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });
// DND KIT // DND KIT
const sensors = useSensors(useSensor(PointerSensor)); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
const deleteAtCursor = useCallback( const deleteAtCursor = useCallback(
(cursor: string | null) => { (cursor: string | null) => {
if (!cursor) return; if (!cursor) return;
const { entry, index } = getPreviousNormal(rundown, order, cursor); const { entry, index } = getPreviousNormal(entries, order, cursor);
deleteEvent([cursor]); deleteEntry([cursor]);
if (entry && index !== null) { if (entry && index !== null) {
setSelectedEvents({ id: entry.id, selectMode: 'click', index }); setSelectedEvents({ id: entry.id, selectMode: 'click', index });
} }
}, },
[rundown, order, deleteEvent, setSelectedEvents], [entries, order, deleteEntry, setSelectedEvents],
); );
const insertCopyAtId = useCallback( const insertCopyAtId = useCallback(
(atId: string | null, copyId: string | null, above = false) => { (atId: string | null, copyId: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, atId ?? '').entry?.id ?? null : atId; const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId;
if (copyId === null) { if (copyId === null) {
// we cant clone without selection // we cant clone without selection
return; return;
} }
const cloneEntry = rundown[copyId]; const cloneEntry = entries[copyId];
if (cloneEntry?.type === SupportedEvent.Event) { if (cloneEntry?.type === SupportedEntry.Event) {
//if we don't have a cursor add the new event on top //if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry); const newEvent = cloneEvent(cloneEntry);
addEvent(newEvent, { after: adjustedCursor ?? undefined }); addEntry(newEvent, { after: adjustedCursor ?? undefined });
} }
}, },
[addEvent, order, rundown], [addEntry, order, entries],
); );
const insertAtId = useCallback( const insertAtId = useCallback(
(type: SupportedEvent, id: MaybeString, above = false) => { (patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: MaybeString, above = false) => {
const options: EventOptions = const options: EventOptions =
id === null id === null
? {} ? {}
@@ -101,19 +119,12 @@ export default function Rundown({ data }: RundownProps) {
before: above ? id : undefined, before: above ? id : undefined,
}; };
if (type === SupportedEvent.Event) { if (!above && id) {
const newEvent = { options.lastEventId = id;
type: SupportedEvent.Event,
};
if (!above && id) {
options.lastEventId = id;
}
addEvent(newEvent, options);
} else {
addEvent({ type }, options);
} }
addEntry(patch, options);
}, },
[addEvent], [addEntry],
); );
const selectBlock = useCallback( const selectBlock = useCallback(
@@ -124,7 +135,7 @@ export default function Rundown({ data }: RundownProps) {
let newCursor = cursor; let newCursor = cursor;
if (cursor === null) { if (cursor === null) {
// there is no cursor, we select the first or last depending on direction // there is no cursor, we select the first or last depending on direction
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order); const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (isOntimeBlock(selected)) { if (isOntimeBlock(selected)) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
@@ -140,14 +151,14 @@ export default function Rundown({ data }: RundownProps) {
// otherwise we select the next or previous // otherwise we select the next or previous
const selected = const selected =
direction === 'up' direction === 'up'
? getPreviousBlockNormal(rundown, order, newCursor) ? getPreviousBlockNormal(entries, order, newCursor)
: getNextBlockNormal(rundown, order, newCursor); : getNextBlockNormal(entries, order, newCursor);
if (selected.entry !== null && selected.index !== null) { if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
} }
}, },
[order, rundown, setSelectedEvents], [order, entries, setSelectedEvents],
); );
const selectEntry = useCallback( const selectEntry = useCallback(
@@ -158,7 +169,7 @@ export default function Rundown({ data }: RundownProps) {
if (cursor === null) { if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists // there is no cursor, we select the first or last depending on direction if it exists
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order); const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (selected !== null) { if (selected !== null) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
} }
@@ -167,29 +178,36 @@ export default function Rundown({ data }: RundownProps) {
// otherwise we select the next or previous // otherwise we select the next or previous
const selected = const selected =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor); direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
if (selected.entry !== null && selected.index !== null) { if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
} }
}, },
[order, rundown, setSelectedEvents], [order, entries, setSelectedEvents],
); );
const moveEntry = useCallback( const moveEntry = useCallback(
(cursor: string | null, direction: 'up' | 'down') => { (cursor: EntryId | null, direction: 'up' | 'down') => {
if (order.length < 2 || cursor == null) { if (sortableData.length < 2 || cursor == null) {
return; return;
} }
const { index } =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
if (index !== null) { const { destinationId, order, isBlock } =
const offsetIndex = direction === 'up' ? index + 1 : index - 1; direction === 'up' ? moveUp(cursor, sortableData, entries) : moveDown(cursor, sortableData, entries);
reorderEvent(cursor, offsetIndex, index);
if (!destinationId) {
return;
} }
// if we are moving into a block, we need to make sure it is expanded
if (isBlock) {
handleCollapseGroup(false, destinationId);
}
reorderEntry(cursor, destinationId, order as 'before' | 'after' | 'insert');
}, },
[order, reorderEvent, rundown], [sortableData, reorderEntry],
); );
// shortcuts // shortcuts
@@ -207,14 +225,14 @@ export default function Rundown({ data }: RundownProps) {
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }], ['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }], ['alt + E', () => insertAtId({ type: SupportedEntry.Event }, cursor), { preventDefault: true }],
['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }], ['alt + shift + E', () => insertAtId({ type: SupportedEntry.Event }, cursor, true), { preventDefault: true }],
['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }], ['alt + B', () => insertAtId({ type: SupportedEntry.Block }, cursor), { preventDefault: true }],
['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }], ['alt + shift + B', () => insertAtId({ type: SupportedEntry.Block }, cursor, true), { preventDefault: true }],
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }], ['alt + D', () => insertAtId({ type: SupportedEntry.Delay }, cursor), { preventDefault: true }],
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }], ['alt + shift + D', () => insertAtId({ type: SupportedEntry.Delay }, cursor, true), { preventDefault: true }],
['mod + C', () => setEntryCopyId(cursor)], ['mod + C', () => setEntryCopyId(cursor)],
['mod + V', () => insertCopyAtId(cursor, entryCopyId)], ['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
@@ -226,11 +244,11 @@ export default function Rundown({ data }: RundownProps) {
// we copy the state from the store here // we copy the state from the store here
// to workaround async updates on the drag mutations // to workaround async updates on the drag mutations
useEffect(() => { useEffect(() => {
setStatefulEntries(order); setSortableData(makeSortableList(order, entries));
}, [order]); }, [order, entries]);
// in run mode, we follow selection
useEffect(() => { useEffect(() => {
// in run mode, we follow selection
if (appMode !== AppMode.Run || !featureData?.selectedEventId) { if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
return; return;
} }
@@ -238,111 +256,235 @@ export default function Rundown({ data }: RundownProps) {
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index }); setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]); }, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
/**
* Checks whether a block is collapsed
*/
const getIsCollapsed = useCallback(
(blockId: EntryId): boolean => {
return Boolean(collapsedGroups.find((id) => id === blockId));
},
[collapsedGroups],
);
/**
* Handles logic for collapsing groups
*/
const handleCollapseGroup = useCallback(
(collapsed: boolean, groupId: EntryId) => {
setCollapsedGroups((prev) => {
const isCollapsed = getIsCollapsed(groupId);
if (collapsed && !isCollapsed) {
const newSet = new Set(prev).add(groupId);
return [...newSet];
}
if (!collapsed && isCollapsed) {
return [...prev].filter((id) => id !== groupId);
}
return prev;
});
},
[getIsCollapsed, setCollapsedGroups],
);
/**
* On drag end, we reorder the events
*/
const handleOnDragEnd = (event: DragEndEvent) => { const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (over?.id) { if (!over?.id || active.id === over.id) {
if (active.id !== over?.id) { return;
const fromIndex = active.data.current?.sortable.index; }
const toIndex = over.data.current?.sortable.index;
// ugly hack to handle inconsistencies between dnd-kit and async store updates const fromIndex = active.data.current?.sortable.index;
setStatefulEntries((currentEntries) => { const toIndex = over.data.current?.sortable.index;
return arrayMove(currentEntries, fromIndex, toIndex);
}); // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
reorderEvent(String(active.id), fromIndex, toIndex); setSortableData((currentEntries) => {
} return reorderArray(currentEntries, fromIndex, toIndex);
});
let destinationId = over.id as EntryId;
let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
/**
* We need to specially handle the end blocks
* Dragging before and end block will add the entry to the end of the block
* Dragging after an end block will add the event after the block itself
*/
if (destinationId.startsWith('end-')) {
destinationId = destinationId.replace('end-', '');
// if we are moving before the end, we use the insert operation
order = 'insert';
}
reorderEntry(active.id as EntryId, destinationId, order);
};
/**
* When we drag a block, we force collapse it
* This avoids strange scenarios like dropping a block inside itself
*/
const collapseDraggedBlocks = (event: DragStartEvent) => {
const isBlock = event.active.data.current?.type === 'block';
if (isBlock) {
handleCollapseGroup(true, event.active.id as EntryId);
} }
}; };
if (statefulEntries.length < 1) { /**
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />; * When we drag over a block, we expand it if it is collapsed
*/
const expandOverBlock = (event: DragOverEvent) => {
// if we are dragging a block, the drop operation is invalid so we dont expand
if (event.active.data.current?.type === 'block') {
return;
}
if (event.over?.data.current?.type !== 'block') {
return;
}
const blockId = event.over?.id as EntryId;
const isCollapsed = getIsCollapsed(blockId);
if (isCollapsed) {
handleCollapseGroup(false, blockId);
}
};
if (sortableData.length < 1) {
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
} }
// last event is used to calculate relative timings // 1. gather presentation options
let lastEvent: PlayableEvent | undefined; // used by indicators
let thisEvent: PlayableEvent | undefined;
// previous entry is used to infer position in the rundown for new events
let previousEntryId: string | undefined;
let thisId = previousEntryId;
let eventIndex = 0;
// all events before the current selected are in the past
let isPast = Boolean(featureData?.selectedEventId);
let isNextDay = false;
let totalGap = 0;
const isEditMode = appMode === AppMode.Edit; const isEditMode = appMode === AppMode.Edit;
let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event
// 2. initialise rundown metadata
const { metadata, process } = makeRundownMetadata(featureData?.selectedEventId);
// keep a single reference to the metadata which we override for every entry
let rundownMetadata = metadata;
return ( return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'> <div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}> <DndContext
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}> onDragEnd={handleOnDragEnd}
onDragStart={collapseDraggedBlocks}
onDragOver={expandOverBlock}
sensors={sensors}
collisionDetection={closestCenter}
>
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
<div className={style.list}> <div className={style.list}>
{statefulEntries.map((entryId, index) => { {sortableData.map((entryId, index) => {
// we iterate through a stateful copy of order to make the operations smoother const isFirst = index === 0;
// this means that this can be out of sync with order until the useEffect runs const isLast = index === sortableData.length - 1;
// instead of writing all the logic guards, we simply short circuit rendering here
const entry = rundown[entryId];
if (!entry) {
return null;
}
if (index === 0) {
eventIndex = 0;
}
isNextDay = false;
previousEntryId = thisId;
thisId = entryId;
if (isOntimeEvent(entry)) {
// event indexes are 1 based in frontend
eventIndex++;
lastEvent = thisEvent;
if (isPlayableEvent(entry)) { // the entry might be a pseudo block-end which does not generate metadata and should not be processed
isNextDay = checkIsNextDay(entry, lastEvent); if (entryId.startsWith('end-')) {
if (!isPast) { const parentId = entryId.split('end-')[1];
totalGap += entry.gap; const isBlockCollapsed = getIsCollapsed(parentId);
// We also include countToEnd in this test as the behavior of a linked event coming after a countToEnd is simelar to an unlinked event
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null && !lastEvent?.countToEnd; if (isBlockCollapsed && isEditMode && isLast) {
} return <QuickAddBlock key={entryId} previousEventId={parentId} parentBlock={null} />;
if (isNewLatest(entry, lastEvent)) { } else if (isBlockCollapsed) {
// populate previous entry return null;
thisEvent = entry; } else {
} const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour;
// if the previous element is selected, it will have its own QuickAddBlock
// we use thisId instead of previousEntryId because the block end does not process
// and it does not cause the reassignment of the iteration id to the previous entry
const showPrependingQuickAdd = isEditMode && cursor !== rundownMetadata.thisId;
return (
<Fragment key={entryId}>
{showPrependingQuickAdd && (
<QuickAddBlock
previousEventId={rundownMetadata.thisId}
parentBlock={parentId}
backgroundColor={parentColour}
/>
)}
<BlockEnd key={entryId} id={entryId} colour={parentColour} />
{isEditMode && isLast && <QuickAddBlock previousEventId={parentId} parentBlock={null} />}
</Fragment>
);
} }
} }
const isFirst = index === 0;
const isLast = index === order.length - 1; // we iterate through a stateful copy of order to make the dnd operations smoother
const isLoaded = featureData?.selectedEventId === entry.id; // this means that this can be out of sync with order until the useEffect runs
// instead of writing all the logic guards, we simply short circuit rendering here
const entry = entries[entryId];
if (!entry) return null;
rundownMetadata = process(entry);
// if the entry has a parent, and it is collapsed, render nothing
if (
entry.type !== SupportedEntry.Block &&
rundownMetadata.groupId !== null &&
getIsCollapsed(rundownMetadata.groupId)
) {
return null;
}
const isNext = featureData?.nextEventId === entry.id; const isNext = featureData?.nextEventId === entry.id;
const hasCursor = entry.id === cursor; const hasCursor = entry.id === cursor;
if (isLoaded) {
isPast = false; /**
} * Outside a block, the value will be undefined
* If the colour is empty string ''
* ie: we are inside a block, but there is no defined colour
* we default to $gray-1050 #303030
*/
const blockColour = rundownMetadata.groupColour === '' ? '#303030' : rundownMetadata.groupColour;
return ( return (
<Fragment key={entry.id}> <Fragment key={entry.id}>
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEntryId} />} {isEditMode && (hasCursor || isFirst) && (
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}> <QuickAddBlock
{isOntimeEvent(entry) && <div className={style.entryIndex}>{eventIndex}</div>} previousEventId={rundownMetadata.previousEntryId}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}> parentBlock={isFirst ? null : rundownMetadata.groupId}
<RundownEntry backgroundColor={isFirst ? undefined : blockColour}
type={entry.type} />
isPast={isPast} )}
eventIndex={eventIndex} {isOntimeBlock(entry) ? (
data={entry} <BlockBlock
loaded={isLoaded} data={entry}
hasCursor={hasCursor} hasCursor={hasCursor}
isNext={isNext} collapsed={getIsCollapsed(entry.id)}
previousEntryId={previousEntryId} onCollapse={handleCollapseGroup}
previousEventId={lastEvent?.id} />
playback={isLoaded ? featureData.playback : undefined} ) : (
isRolling={featureData.playback === Playback.Roll} <div
isNextDay={isNextDay} className={style.entryWrapper}
totalGap={totalGap} data-testid={`entry-${rundownMetadata.eventIndex}`}
isLinkedToLoaded={isLinkedToLoaded} style={blockColour ? { '--user-bg': blockColour } : {}}
/> >
{isOntimeEvent(entry) && <div className={style.entryIndex}>{rundownMetadata.eventIndex}</div>}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
isPast={rundownMetadata.isPast}
eventIndex={rundownMetadata.eventIndex}
data={entry}
loaded={rundownMetadata.isLoaded}
hasCursor={hasCursor}
isNext={isNext}
previousEntryId={rundownMetadata.previousEntryId}
previousEventId={rundownMetadata.previousEvent?.id}
playback={rundownMetadata.isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
isNextDay={rundownMetadata.isNextDay}
totalGap={rundownMetadata.totalGap}
isLinkedToLoaded={rundownMetadata.isLinkedToLoaded}
/>
</div>
</div> </div>
</div> )}
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={entry.id} />} {isEditMode && (hasCursor || isLast) && (
<QuickAddBlock
previousEventId={entry.id}
parentBlock={rundownMetadata.groupId}
backgroundColor={blockColour}
/>
)}
</Fragment> </Fragment>
); );
})} })}
@@ -1,23 +1,30 @@
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { SupportedEntry } from 'ontime-types';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
import style from './Rundown.module.scss'; import style from './Empty.module.scss';
interface RundownEmptyProps { interface RundownEmptyProps {
handleAddNew: () => void; handleAddNew: (type: SupportedEntry) => void;
} }
export default function RundownEmpty(props: RundownEmptyProps) { export default function RundownEmpty(props: RundownEmptyProps) {
const { handleAddNew } = props; const { handleAddNew } = props;
return ( return (
<div className={style.alignCenter}> <div className={style.empty}>
<Empty style={{ marginTop: '7vh' }} /> <Empty style={{ marginTop: '5vh', marginBottom: '3rem' }} />
<Button onClick={handleAddNew} variant='ontime-filled' className={style.spaceTop} leftIcon={<IoAdd />}> <div className={style.inline}>
Create Event <Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='ontime-filled' leftIcon={<IoAdd />}>
</Button> Create Event
</Button>
<Button onClick={() => handleAddNew(SupportedEntry.Block)} variant='ontime-filled' leftIcon={<IoAdd />}>
Create Block
</Button>
</div>
</div> </div>
); );
} }
@@ -1,40 +1,46 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types'; import {
isOntimeDelay,
isOntimeEvent,
MaybeString,
OntimeEntry,
OntimeEvent,
Playback,
SupportedEntry,
} from 'ontime-types';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEntryActions } from '../../common/hooks/useEntryAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn'; import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { useEmitLog } from '../../common/stores/logger'; import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/eventsManager'; import { cloneEvent } from '../../common/utils/clone';
import BlockBlock from './block-block/BlockBlock';
import DelayBlock from './delay-block/DelayBlock'; import DelayBlock from './delay-block/DelayBlock';
import EventBlock from './event-block/EventBlock'; import EventBlock from './event-block/EventBlock';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
export type EventItemActions = export type EventItemActions =
| 'set-cursor'
| 'event' | 'event'
| 'event-before' | 'event-before'
| 'delay' | 'delay'
| 'delay-before' | 'delay-before'
| 'block' | 'block'
| 'block-before' | 'block-before'
| 'swap'
| 'delete' | 'delete'
| 'clone' | 'clone'
| 'update' | 'group'
| 'swap' | 'update';
| 'clear-report';
interface RundownEntryProps { interface RundownEntryProps {
type: SupportedEvent; type: SupportedEntry;
isPast: boolean; isPast: boolean;
data: OntimeRundownEntry; data: OntimeEntry;
loaded: boolean; loaded: boolean;
eventIndex: number; eventIndex: number;
hasCursor: boolean; hasCursor: boolean;
isNext: boolean; isNext: boolean;
isNextDay: boolean; isNextDay: boolean;
previousEntryId?: string; previousEntryId: MaybeString;
previousEventId?: string; previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event isRolling: boolean; // we need to know even if not related to this event
@@ -59,7 +65,7 @@ export default function RundownEntry(props: RundownEntryProps) {
isLinkedToLoaded, isLinkedToLoaded,
} = props; } = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction(); const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection(); const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
const removeOpenEvent = useCallback(() => { const removeOpenEvent = useCallback(() => {
@@ -79,31 +85,31 @@ export default function RundownEntry(props: RundownEntryProps) {
const actionHandler = useMemoisedFn((action: EventItemActions, payload?: number | FieldValue) => { const actionHandler = useMemoisedFn((action: EventItemActions, payload?: number | FieldValue) => {
switch (action) { switch (action) {
case 'event': { case 'event': {
const newEvent = { type: SupportedEvent.Event }; const newEvent = { type: SupportedEntry.Event };
const options = { const options = {
after: data.id, after: data.id,
lastEventId: previousEventId, lastEventId: previousEventId,
}; };
return addEvent(newEvent, options); return addEntry(newEvent, options);
} }
case 'event-before': { case 'event-before': {
const newEvent = { type: SupportedEvent.Event }; const newEvent = { type: SupportedEntry.Event };
const options = { const options = {
after: previousEntryId, after: previousEntryId,
}; };
return addEvent(newEvent, options); return addEntry(newEvent, options);
} }
case 'delay': { case 'delay': {
return addEvent({ type: SupportedEvent.Delay }, { after: data.id }); return addEntry({ type: SupportedEntry.Delay }, { after: data.id });
} }
case 'delay-before': { case 'delay-before': {
return addEvent({ type: SupportedEvent.Delay }, { after: previousEntryId }); return addEntry({ type: SupportedEntry.Delay }, { after: previousEntryId });
} }
case 'block': { case 'block': {
return addEvent({ type: SupportedEvent.Block }, { after: data.id }); return addEntry({ type: SupportedEntry.Block }, { after: data.id });
} }
case 'block-before': { case 'block-before': {
return addEvent({ type: SupportedEvent.Block }, { after: previousEntryId }); return addEntry({ type: SupportedEntry.Block }, { after: previousEntryId });
} }
case 'swap': { case 'swap': {
const { value } = payload as FieldValue; const { value } = payload as FieldValue;
@@ -112,14 +118,21 @@ export default function RundownEntry(props: RundownEntryProps) {
case 'delete': { case 'delete': {
if (selectedEvents.size > 1) { if (selectedEvents.size > 1) {
clearMultiSelection(); clearMultiSelection();
return deleteEvent(Array.from(selectedEvents)); return deleteEntry(Array.from(selectedEvents));
} }
removeOpenEvent(); removeOpenEvent();
return deleteEvent([data.id]); return deleteEntry([data.id]);
} }
case 'clone': { case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent); const newEvent = cloneEvent(data as OntimeEvent);
addEvent(newEvent, { after: data.id }); addEntry(newEvent, { after: data.id });
break;
}
case 'group': {
if (selectedEvents.size > 1) {
clearMultiSelection();
return groupEntries(Array.from(selectedEvents));
}
break; break;
} }
case 'update': { case 'update': {
@@ -140,7 +153,7 @@ export default function RundownEntry(props: RundownEntryProps) {
if (field in data) { if (field in data) {
// @ts-expect-error -- not sure how to type this // @ts-expect-error -- not sure how to type this
newData[field] = value; newData[field] = value;
return updateEvent(newData); return updateEntry(newData);
} }
return emitError(`Unknown field: ${field}`); return emitError(`Unknown field: ${field}`);
@@ -150,7 +163,7 @@ export default function RundownEntry(props: RundownEntryProps) {
} }
}); });
if (data.type === SupportedEvent.Event) { if (isOntimeEvent(data)) {
return ( return (
<EventBlock <EventBlock
eventId={data.id} eventId={data.id}
@@ -167,11 +180,12 @@ export default function RundownEntry(props: RundownEntryProps) {
timerType={data.timerType} timerType={data.timerType}
title={data.title} title={data.title}
note={data.note} note={data.note}
delay={data.delay ?? 0} delay={data.delay}
colour={data.colour} colour={data.colour}
isPast={isPast} isPast={isPast}
isNext={isNext} isNext={isNext}
skip={data.skip} skip={data.skip}
parent={data.parent}
loaded={loaded} loaded={loaded}
hasCursor={hasCursor} hasCursor={hasCursor}
playback={playback} playback={playback}
@@ -184,9 +198,7 @@ export default function RundownEntry(props: RundownEntryProps) {
actionHandler={actionHandler} actionHandler={actionHandler}
/> />
); );
} else if (data.type === SupportedEvent.Block) { } else if (isOntimeDelay(data)) {
return <BlockBlock data={data} hasCursor={hasCursor} onDelete={() => actionHandler('delete')} />;
} else if (data.type === SupportedEvent.Delay) {
return <DelayBlock data={data} hasCursor={hasCursor} />; return <DelayBlock data={data} hasCursor={hasCursor} />;
} }
return null; return null;
@@ -25,7 +25,7 @@
.list { .list {
@include editor.panel; @include editor.panel;
height: inherit; height: inherit;
padding-left: 0; padding-inline: 0;
box-shadow: $box-shadow-right; box-shadow: $box-shadow-right;
flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */ flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */
@@ -8,11 +8,14 @@ import { cx } from '../../common/utils/styleUtils';
import { Corner } from '../editors/editor-utils/EditorUtils'; import { Corner } from '../editors/editor-utils/EditorUtils';
import RundownEventEditor from './event-editor/RundownEventEditor'; import RundownEventEditor from './event-editor/RundownEventEditor';
import FinderPlacement from './placements/FinderPlacement';
import RundownWrapper from './RundownWrapper'; import RundownWrapper from './RundownWrapper';
import style from './RundownExport.module.scss'; import style from './RundownExport.module.scss';
const RundownExport = () => { export default memo(RundownExport);
function RundownExport() {
const isExtracted = window.location.pathname.includes('/rundown'); const isExtracted = window.location.pathname.includes('/rundown');
const appMode = useAppMode((state) => state.mode); const appMode = useAppMode((state) => state.mode);
const hideSideBar = isExtracted && appMode === 'run'; const hideSideBar = isExtracted && appMode === 'run';
@@ -21,6 +24,7 @@ const RundownExport = () => {
return ( return (
<div className={classes} data-testid='panel-rundown'> <div className={classes} data-testid='panel-rundown'>
<FinderPlacement />
<div className={style.rundown}> <div className={style.rundown}>
<div className={style.list}> <div className={style.list}>
<ErrorBoundary> <ErrorBoundary>
@@ -40,6 +44,4 @@ const RundownExport = () => {
</div> </div>
</div> </div>
); );
}; }
export default memo(RundownExport);
@@ -0,0 +1,399 @@
import { EntryId, OntimeBlock, OntimeDelay, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types';
import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from '../rundown.utils';
describe('makeRundownMetadata()', () => {
it('processes nested rundown data', () => {
const selectedEventId = '12';
const demoEvents = {
'1': {
id: '1',
type: SupportedEntry.Event,
parent: null,
timeStart: 0,
timeEnd: 1,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
block: {
id: 'block',
type: SupportedEntry.Block,
events: ['11', 'delay', '12', '13'],
colour: 'red',
} as OntimeBlock,
'11': {
id: '11',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 10,
timeEnd: 11,
duration: 1,
dayOffset: 0,
gap: 10,
skip: false,
linkStart: false,
} as OntimeEvent,
delay: {
id: 'delay',
type: SupportedEntry.Delay,
parent: 'block',
duration: 0,
} as OntimeDelay,
'12': {
id: '12',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 11,
timeEnd: 12,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'13': {
id: '13',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 12,
timeEnd: 13,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: true,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: null,
timeStart: 20,
timeEnd: 21,
duration: 1,
dayOffset: 0,
gap: 7,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { metadata, process } = makeRundownMetadata(selectedEventId);
expect(metadata).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: null,
eventIndex: 0,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
});
expect(process(demoEvents['1'])).toStrictEqual({
previousEvent: null,
latestEvent: demoEvents['1'],
previousEntryId: null,
thisId: demoEvents['1'].id,
eventIndex: 1, // UI indexes are 1 based
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
});
expect(process(demoEvents['block'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['1'],
previousEntryId: demoEvents['1'].id,
thisId: demoEvents['block'].id,
eventIndex: 1,
isPast: true,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['11'])).toMatchObject({
previousEvent: demoEvents['1'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['block'].id,
thisId: demoEvents['11'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['delay'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['11'].id,
thisId: demoEvents['delay'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['12'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['12'],
previousEntryId: demoEvents['delay'].id,
thisId: demoEvents['12'].id,
eventIndex: 3,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: true,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['13'])).toMatchObject({
previousEvent: demoEvents['12'],
latestEvent: demoEvents['13'],
previousEntryId: demoEvents['12'].id,
thisId: demoEvents['13'].id,
eventIndex: 4,
isPast: false,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: true,
isLoaded: false,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['2'])).toMatchObject({
previousEvent: demoEvents['13'],
latestEvent: demoEvents['2'],
previousEntryId: demoEvents['13'].id,
thisId: demoEvents['2'].id,
eventIndex: 5,
isPast: false,
isNextDay: false,
totalGap: 17,
isLinkedToLoaded: false,
isLoaded: false,
groupId: null,
groupColour: undefined,
});
});
it('populates previousEntries in blocks', () => {
const rundownStartsWithBlock = {
block: {
id: 'block',
type: SupportedEntry.Block,
colour: 'red',
events: ['1', '2'],
} as OntimeBlock,
'1': {
id: '1',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 1,
timeEnd: 2,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
'2': {
id: '2',
type: SupportedEntry.Event,
parent: 'block',
timeStart: 2,
timeEnd: 3,
duration: 1,
dayOffset: 0,
gap: 0,
skip: false,
linkStart: false,
} as OntimeEvent,
};
const { process } = makeRundownMetadata(null);
expect(process(rundownStartsWithBlock.block)).toStrictEqual({
previousEvent: null,
latestEvent: null,
previousEntryId: null,
thisId: rundownStartsWithBlock.block.id,
eventIndex: 0,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithBlock.block.id,
groupColour: 'red',
});
expect(process(rundownStartsWithBlock['1'])).toStrictEqual({
previousEvent: null,
latestEvent: rundownStartsWithBlock['1'],
previousEntryId: rundownStartsWithBlock.block.id,
thisId: rundownStartsWithBlock['1'].id,
eventIndex: 1,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithBlock.block.id,
groupColour: 'red',
});
expect(process(rundownStartsWithBlock['2'])).toStrictEqual({
previousEvent: rundownStartsWithBlock['1'],
latestEvent: rundownStartsWithBlock['2'],
previousEntryId: rundownStartsWithBlock['1'].id,
thisId: rundownStartsWithBlock['2'].id,
eventIndex: 2,
isPast: false,
isNextDay: false,
totalGap: 0,
isLinkedToLoaded: false,
isLoaded: false,
groupId: rundownStartsWithBlock.block.id,
groupColour: 'red',
});
});
});
describe('makeSortableList()', () => {
it('generates a list with block ends', () => {
const order = ['block-1', '2', 'block-3', 'block-4'];
const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: ['11'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
'2': { type: SupportedEntry.Event, id: '2', parent: null } as OntimeEvent,
'block-3': { type: SupportedEntry.Block, id: 'block-3', events: ['31'] } as OntimeBlock,
'31': { type: SupportedEntry.Event, id: '31', parent: 'block-3' } as OntimeEvent,
'block-4': { type: SupportedEntry.Block, id: 'block-4', events: [] as string[] } as OntimeBlock,
};
const sortableList = makeSortableList(order, entries);
expect(sortableList).toStrictEqual([
'block-1',
'11',
'end-block-1',
'2',
'block-3',
'31',
'end-block-3',
'block-4',
'end-block-4',
]);
});
it('closes dangling blocks', () => {
const order = ['block'];
const entries: RundownEntries = {
block: { type: SupportedEntry.Block, id: 'block-1', events: ['11', '12'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
'12': { type: SupportedEntry.Event, id: '12', parent: 'block-1' } as OntimeEvent,
};
const sortableList = makeSortableList(order, entries);
expect(sortableList).toStrictEqual(['block-1', '11', '12', 'end-block-1']);
});
it('handles a list with a with just blocks', () => {
const order = ['block-1', 'block-2'];
const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: [] as string[] } as OntimeBlock,
'block-2': { type: SupportedEntry.Block, id: 'block-2', events: [] as string[] } as OntimeBlock,
};
const sortableList = makeSortableList(order, entries);
expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']);
});
});
describe('moveUp()', () => {
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
const entries = {
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
block1: { type: 'block', id: 'block1', events: ['event3'] } as OntimeBlock,
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
};
it('moves an event up in the list', () => {
const result = moveUp('event2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event1', order: 'before', isBlock: false });
})
it.todo('disallows nesting blocks', () => {
const result = moveUp('block2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block1', order: 'before', isBlock: false });
})
it('moves an event into a block', () => {
const result = moveUp('event3', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block2', order: 'insert', isBlock: true });
})
it('moving up from top is noop', () => {
const result = moveUp('event1', sortableData, entries);
expect(result).toMatchObject({ destinationId: null });
})
});
describe('moveDown()', () => {
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
const entries = {
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
block1: { type: 'block', id: 'block1', events: ['event11'] } as OntimeBlock,
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
};
it('moves an event down in the list', () => {
const result = moveDown('event1', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event2', order: 'after', isBlock: false });
})
it.todo('disallows nesting blocks', () => {
const result = moveDown('block1', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block2', order: 'before', isBlock: false });
})
it('moves an event into a block', () => {
const result = moveDown('event2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event11', order: 'before', isBlock: true });
})
it('moving down from bottom is noop', () => {
const result = moveDown('event3', sortableData, entries);
expect(result).toMatchObject({ destinationId: null });
})
});
@@ -1,7 +1,7 @@
@use '../../theme/ontimeColours' as *; @use '../../theme/ontimeColours' as *;
@use '../../theme/ontimeStyles' as *; @use '../../theme/ontimeStyles' as *;
$block-width: 33rem; $block-width: 32rem;
$block-gap: 0.25rem; $block-gap: 0.25rem;
$block-element-spacing: 0.25rem; $block-element-spacing: 0.25rem;
@@ -20,28 +20,23 @@ $block-cursor-color: $orange-400;
box-sizing: content-box; box-sizing: content-box;
border: 1px solid $white-7; border: 1px solid $white-7;
border-radius: $block-border-radius; border-radius: $block-border-radius;
margin-block: 0.25rem;
margin-right: 0.125rem;
position: relative; position: relative;
color: $block-text-color; color: $block-text-color;
min-width: $block-width; min-width: $block-width;
} }
@mixin block-spacing() {
padding-right: 0.5rem;
gap: 2px;
}
@mixin drag-style() { @mixin drag-style() {
font-size: 20px; font-size: 20px;
justify-self: center; justify-self: center;
opacity: 0.3; opacity: 0.3;
cursor: grab; cursor: grab;
transition: opacity 0.3s; transition: opacity 0.3s;
&:hover { &:hover {
opacity: 1; opacity: 1;
} }
&:focus { &:focus {
box-shadow: none; box-shadow: none;
outline: none; outline: none;
@@ -1,28 +1,74 @@
@use '../blockMixins' as *; @use '../blockMixins' as *;
.block { .block {
@include block-spacing;
@include block-styling; @include block-styling;
margin-left: 0.5rem; overflow: hidden;
background-color: $block-bg2;
min-width: 34rem;
display: grid; display: grid;
grid-template-columns: 2rem 1fr auto; grid-template-columns: 2rem 1fr;
grid-template-areas: 'binder header';
align-items: center; align-items: center;
height: $secondary-block-height; // TODO(style fix): groups have an extra bottom margin which interrupt colour
gap: 0.5rem; margin-block: 0.25rem;
&.hasCursor { &.hasCursor {
outline: 1px solid $block-cursor-color; outline: 1px solid $block-cursor-color;
} }
&.expanded {
border-radius: $block-border-radius $block-border-radius 0 0;
}
.binder {
grid-area: binder;
height: 100%;
background-color: var(--block-color, $gray-1050);
color: $section-white;
font-size: 1rem;
display: grid;
justify-content: center;
padding-top: 0.25rem;
}
.header {
grid-area: header;
padding-inline: 0.5rem;
background-color: $block-bg2;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.titleRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.metaRow {
display: flex;
gap: 3rem;
margin-bottom: 0.25rem;
}
.metaEntry {
width: 4.5em;
:first-child {
font-size: calc(1rem - 3px);
color: $label-gray;
}
}
} }
.drag { .drag {
@include drag-style; @include drag-style;
}
.actionMenu { &.isDragging {
justify-self: flex-end; cursor: grabbing;
}
&.notAllowed {
cursor: not-allowed;
}
} }
@@ -1,26 +1,57 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { IoReorderTwo } from 'react-icons/io5'; import {
IoChevronDown,
IoChevronUp,
IoDuplicateOutline,
IoFolderOpenOutline,
IoReorderTwo,
IoTrash,
} from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { OntimeBlock } from 'ontime-types'; import { EntryId, OntimeBlock } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import EditableBlockTitle from '../common/EditableBlockTitle'; import EditableBlockTitle from '../common/EditableBlockTitle';
import { canDrop } from '../rundown.utils';
import BlockDelete from './BlockDelete';
import style from './BlockBlock.module.scss'; import style from './BlockBlock.module.scss';
interface BlockBlockProps { interface BlockBlockProps {
data: OntimeBlock; data: OntimeBlock;
hasCursor: boolean; hasCursor: boolean;
onDelete: () => void; collapsed: boolean;
onCollapse: (collapsed: boolean, groupId: EntryId) => void;
} }
export default function BlockBlock(props: BlockBlockProps) { export default function BlockBlock(props: BlockBlockProps) {
const { data, hasCursor, onDelete } = props; const { data, hasCursor, collapsed, onCollapse } = props;
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useEntryActions();
const [onContextMenu] = useContextMenu<HTMLDivElement>([
{
label: 'Clone Block',
icon: IoDuplicateOutline,
onClick: () => clone(data.id),
},
{
label: 'Ungroup',
icon: IoFolderOpenOutline,
onClick: () => ungroup(data.id),
isDisabled: data.events.length === 0,
},
{
label: 'Delete Block',
icon: IoTrash,
onClick: () => deleteEntry([data.id]),
withDivider: true,
},
]);
const { const {
attributes: dragAttributes, attributes: dragAttributes,
@@ -28,25 +59,79 @@ export default function BlockBlock(props: BlockBlockProps) {
setNodeRef, setNodeRef,
transform, transform,
transition, transition,
isDragging,
isOver,
over,
} = useSortable({ } = useSortable({
id: data.id, id: data.id,
data: {
type: 'block',
},
animateLayoutChanges: () => false, animateLayoutChanges: () => false,
}); });
const binderColours = data.colour && getAccessibleColour(data.colour);
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
const dragStyle = { const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform), transform: CSS.Translate.toString(transform),
transition, transition,
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'default',
}; };
const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]);
return ( return (
<div className={blockClasses} ref={setNodeRef} style={dragStyle}> <div
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}> className={cx([style.block, hasCursor && style.hasCursor, !collapsed && style.expanded])}
<IoReorderTwo /> ref={setNodeRef}
</span> onContextMenu={onContextMenu}
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' /> style={{
<BlockDelete onDelete={onDelete} /> ...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
...dragStyle,
}}
>
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
<span
className={cx([style.drag, isDragging && style.isDragging, isDragging && !isValidDrop && style.notAllowed])}
ref={handleRef}
{...dragAttributes}
{...dragListeners}
>
<IoReorderTwo />
</span>
</div>
<div className={style.header}>
<div className={style.titleRow}>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<IconButton
aria-label='Collapse'
onClick={() => onCollapse(!collapsed, data.id)}
color='#e2e2e2' // $gray-200
variant='ontime-ghosted'
size='sm'
>
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
</IconButton>
</div>
<div className={style.metaRow}>
<div className={style.metaEntry}>
<div>Start</div>
<div>{formatTime(data.startTime)}</div>
</div>
<div className={style.metaEntry}>
<div>End</div>
<div>{formatTime(data.endTime)}</div>
</div>
<div className={style.metaEntry}>
<div>Duration</div>
<div>{formatDuration(data.duration)}</div>
</div>
<div className={style.metaEntry}>
<div>Events</div>
<div>{data.events.length}</div>
</div>
</div>
</div>
</div> </div>
); );
} }

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