Compare commits

..

49 Commits

Author SHA1 Message Date
arc-alex 6348defd42 test level db 2025-05-30 13:07:42 +02:00
Carlos Valente a89f5b93da refactor: improve reorder logic 2025-05-28 19:09:49 +02:00
Carlos Valente b919f00501 chore: simplify URLs 2025-05-28 19:09:49 +02:00
Carlos Valente 8b37f6cc99 refactor: order is single source of truth 2025-05-28 19:09:49 +02:00
Carlos Valente 9f27832b29 refactor: refetch targets is enum 2025-05-28 19:09:49 +02:00
Carlos Valente 256755bf02 refactor: small ux improvements
- rename dissolve > ungroup
- prevent ondrag when clicking
- add untitled as block title fallback
- move block action to context menu
2025-05-28 19:09:49 +02:00
Carlos Valente b0e42811a5 refactor: remove trivially inferred numEvents 2025-05-28 19:09:49 +02:00
Carlos Valente f68b3bf057 feat: duplicate groups 2025-05-28 19:09:49 +02:00
Carlos Valente 7d5291e5e5 feat: create group from entry selection 2025-05-28 19:09:49 +02:00
Carlos Valente 67f914bdc0 feat: create block from rundown empty 2025-05-28 19:09:49 +02:00
Carlos Valente 0a44bfe783 fix: collapsed blocks dont render children 2025-05-28 19:09:49 +02:00
Carlos Valente 4a83983161 refactor: type cleanup and test improvements 2025-05-28 19:09:49 +02:00
Carlos Valente 817f0dd4de feat: allow dissolving a block 2025-05-28 19:09:49 +02:00
Carlos Valente a6cb7e5b20 fix: uncontrolled prop on controlled component 2025-05-28 19:09:49 +02:00
Carlos Valente 8232e64cc6 refactor: improve return of reorder 2025-05-28 19:09:49 +02:00
Carlos Valente 1ee773a426 refactor: mutations on batch elements must have IDs 2025-05-28 19:09:49 +02:00
Carlos Valente 88a0bd62be chore: upgrade dependencies 2025-05-28 19:09:49 +02:00
Carlos Valente 918140086c refactor: extract utility to merge two arrays 2025-05-28 19:09:49 +02:00
Carlos Valente df55b0bf8d refactor: change network mode defaults 2025-05-28 19:09:49 +02:00
Carlos Valente 21353b5b00 fix: delete nested events 2025-05-28 19:09:49 +02:00
Carlos Valente d351778ef2 fix: add event at end of block 2025-05-28 19:09:49 +02:00
Carlos Valente deffbdaa43 refactor: make finder available in exported rundown 2025-05-22 08:38:06 +02:00
Alex Christoffer Rasmussen abd4369049 assert non null and update test (#1604) 2025-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen 75c6016f4e 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-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen bdcec7a472 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-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen 0a596624d5 Refactor: ms for api calls (#1593)
* use ms for api calls

* change it in UI

* update comment

* rename variables

* refactor: addtime api logic
2025-05-14 20:07:42 +02:00
Alex Christoffer Rasmussen 94b80d3b0b fix test (#1601)
* fix test

* add comment
2025-05-14 20:07:42 +02:00
Carlos Valente 92fbef3515 fix: rebase master 2025-05-14 20:07:42 +02:00
Carlos Valente 10c2f0cf6c chore: correct test path 2025-05-14 20:07:42 +02:00
Carlos Valente e30b4fa8d5 refactor: extract rundown parsing
refactor: implement groups in editor
2025-05-14 20:07:42 +02:00
Carlos Valente 52358d75d3 chore: improve convention entry <> event 2025-05-14 20:07:42 +02:00
Carlos Valente a55dfba4ef refactor: maintain flat orders 2025-05-14 20:07:42 +02:00
Carlos Valente ed7f8a0aa2 refactor: implement operations on nested events 2025-05-14 20:07:42 +02:00
Carlos Valente ba1292141b refactor: process events in rundown 2025-05-14 20:07:42 +02:00
Carlos Valente 57bd317dcf chore: improve convention entry <> event 2025-05-14 20:07:42 +02:00
Carlos Valente 0cae37d6c8 chore: rename currentBlock > parent 2025-05-14 20:07:42 +02:00
Carlos Valente 6c8ddeec23 refactor: fix delay positioning in gaps 2025-05-14 20:07:42 +02:00
Carlos Valente 3f3ecce7b2 refactor(e2e): skip flaky test 2025-05-14 20:07:42 +02:00
Carlos Valente 402d10055b refactor: improve project loading 2025-05-14 20:07:42 +02:00
Carlos Valente 8f5a4f4a0a refactor: gather group metadata 2025-05-14 20:07:42 +02:00
Carlos Valente 7a78ed4720 refactor: swap maintains schedule 2025-05-14 20:07:42 +02:00
Carlos Valente de0ac92052 chore: rename files 2025-05-14 20:07:42 +02:00
Carlos Valente 58023dce0d refactor: restructure model to contain an object of rundowns 2025-05-14 20:07:42 +02:00
Carlos Valente 0ae7178046 refactor: clearer relationship on rundown elements 2025-05-14 20:07:42 +02:00
Carlos Valente 6b01338118 refactor: use strict typing 2025-05-14 20:07:42 +02:00
Carlos Valente 7287cf8511 refactor: remove stop as a possible end action 2025-05-14 20:07:11 +02:00
Carlos Valente 6d17f188a6 refactor: restructure model to contain an object of rundowns 2025-05-14 20:07:11 +02:00
Carlos Valente 440a9b81b6 chore: remove IDE files 2025-05-14 19:58:30 +02:00
Carlos Valente 5fafa08723 refactor: remove unused and legacy code
- remove legacy migrations
- remove unused server code
- remove unused UI code
2025-05-14 19:58:30 +02:00
280 changed files with 9688 additions and 9915 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

+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: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- 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: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- 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: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- 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: 22 node-version: 20
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: 10 version: 9
- 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: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- 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: 22 node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 9
- 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 @@
v22.15.1 v20.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__ (~22) - __node__ (~20)
- __pnpm__ (~10) - __pnpm__ (~9)
- __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:22-bullseye AS builder FROM node:20-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@10.11.0 RUN npm install -g pnpm@9.5.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:22-alpine FROM node:20-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.16.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.16.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.5.1", "@vitejs/plugin-react": "^4.2.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": "6.3.1", "vite": "^5.2.11",
"vite-plugin-compression2": "1.4.0", "vite-plugin-compression2": "^1.3.3",
"vite-plugin-svgr": "4.3.0", "vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "^4.3.1",
"vitest": "catalog:" "vitest": "catalog:"
} }
} }
+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 -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<MessageResponse>> {
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<MessageResponse>> {
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
*/ */
@@ -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>
);
}
@@ -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);
@@ -1,7 +1,6 @@
import { IoChevronDown } from 'react-icons/io5'; import { IoChevronDown } from 'react-icons/io5';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { baseURI } from '../../../externals';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
@@ -18,7 +17,7 @@ interface ViewParamsSectionProps {
export default function ViewParamsSection(props: ViewParamsSectionProps) { export default function ViewParamsSection(props: ViewParamsSectionProps) {
const { title, collapsible, options } = props; const { title, collapsible, options } = props;
const [collapsed, setCollapsed] = useLocalStorage({ key: `${baseURI}params-${title}`, defaultValue: false }); const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const handleCollapse = () => { const handleCollapse = () => {
if (collapsible) { if (collapsible) {
@@ -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,6 +1,5 @@
import { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react'; import { createContext, PropsWithChildren, useCallback, useEffect, useState } from 'react';
import { baseURI } from '../../externals';
import useSettings from '../hooks-query/useSettings'; import useSettings from '../hooks-query/useSettings';
interface AppContextType { interface AppContextType {
@@ -27,7 +26,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
useEffect(() => { useEffect(() => {
if (status === 'pending') return; if (status === 'pending') return;
const previousEditor = sessionStorage.getItem(`${baseURI}${storageKeys.editor}`); const previousEditor = sessionStorage.getItem(storageKeys.editor);
if (previousEditor && previousEditor === data.editorKey) { if (previousEditor && previousEditor === data.editorKey) {
setEditorAuth(true); setEditorAuth(true);
@@ -35,7 +34,7 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
setEditorAuth(data.editorKey == null || data.editorKey === ''); setEditorAuth(data.editorKey == null || data.editorKey === '');
} }
const previousOperator = sessionStorage.getItem(`${baseURI}${storageKeys.operator}`); const previousOperator = sessionStorage.getItem(storageKeys.operator);
if (previousOperator && previousOperator === data.operatorKey) { if (previousOperator && previousOperator === data.operatorKey) {
setOperatorAuth(true); setOperatorAuth(true);
} else { } else {
@@ -56,14 +55,14 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
if (permission === 'editor') { if (permission === 'editor') {
const correct = isValid(pin, data.editorKey); const correct = isValid(pin, data.editorKey);
if (correct) { if (correct) {
sessionStorage.setItem(`${baseURI}${storageKeys.editor}`, pin); sessionStorage.setItem(storageKeys.editor, pin);
} }
setEditorAuth(correct); setEditorAuth(correct);
return correct; return correct;
} else if (permission === 'operator') { } else if (permission === 'operator') {
const correct = isValid(pin, data.operatorKey); const correct = isValid(pin, data.operatorKey);
if (correct) { if (correct) {
sessionStorage.setItem(`${baseURI}${storageKeys.operator}`, pin); sessionStorage.setItem(storageKeys.operator, pin);
} }
setOperatorAuth(correct); setOperatorAuth(correct);
return correct; return correct;
@@ -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.order.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.order, 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);
@@ -1,29 +1,35 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId,
isOntimeBlock,
isOntimeEvent, isOntimeEvent,
MaybeString,
OntimeBlock, OntimeBlock,
OntimeDelay, OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeRundownEntry, Rundown,
RundownCached,
TimeField, TimeField,
TimeStrategy, TimeStrategy,
TransientEventPayload, TransientEventPayload,
} from 'ontime-types'; } from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils'; 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 { RUNDOWN } from '../api/constants';
import { import {
deleteEntries,
patchReorderEntry,
postAddEntry,
postCloneEntry,
putBatchEditEvents,
putEditEntry,
ReorderEntry, ReorderEntry,
requestApplyDelay, requestApplyDelay,
requestBatchPutEvents,
requestDelete,
requestDeleteAll, requestDeleteAll,
requestEventSwap, requestEventSwap,
requestPostEvent, requestGroupEntries,
requestPutEvent, requestUngroup,
requestReorderEvent,
SwapEntry, SwapEntry,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
@@ -31,18 +37,18 @@ import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{ export type EventOptions = Partial<{
// options to any new block (event / delay / block) // options to any new block (event / delay / block)
after: string; after: MaybeString;
before: string; before: MaybeString;
// options to blocks of type OntimeEvent // options to blocks of type OntimeEvent
defaultPublic: boolean; defaultPublic: boolean;
linkPrevious: boolean; linkPrevious: boolean;
lastEventId: string; lastEventId: MaybeString;
}>; }>;
/** /**
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on * Gather utilities for actions on entries
*/ */
export const useEventAction = () => { export const useEntryActions = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
defaultPublic, defaultPublic,
@@ -55,38 +61,36 @@ export const useEventAction = () => {
defaultEndAction, defaultEndAction,
} = useEditorSettings(); } = useEditorSettings();
const getEventById = useCallback( const getEntryById = useCallback(
(eventId: string) => { (eventId: string): OntimeEntry | undefined => {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN); const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.rundown) { if (!cachedRundown?.entries) {
return; return;
} }
return cachedRundown.rundown[eventId]; return cachedRundown.entries[eventId];
}, },
[queryClient], [queryClient],
); );
/** /**
* Calls mutation to add new event * Calls mutation to add new entry
* @private * @private
*/ */
const _addEventMutation = useMutation({ const _addEntryMutation = useMutation({
mutationFn: requestPostEvent, // TODO(v4): optimistic create entry
onSettled: () => { mutationFn: postAddEntry,
queryClient.invalidateQueries({ queryKey: RUNDOWN }); onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
},
networkMode: 'always',
}); });
/** /**
* Adds an event to rundown * Adds an entry to rundown
*/ */
const addEvent = useCallback( const addEntry = useCallback(
async (event: Partial<OntimeEvent | OntimeDelay | OntimeBlock>, options?: EventOptions) => { async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
const newEvent: TransientEventPayload = { ...event }; const newEntry: TransientEventPayload = { ...entry, id: generateId() };
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEvent)) { if (isOntimeEvent(newEntry)) {
// merge creation time options with event settings // merge creation time options with event settings
const applicationOptions = { const applicationOptions = {
after: options?.after, after: options?.after,
@@ -96,66 +100,60 @@ export const useEventAction = () => {
linkPrevious: options?.linkPrevious ?? linkPrevious, linkPrevious: options?.linkPrevious ?? linkPrevious,
}; };
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) { if (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 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!; const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
const { rundown } = rundownData; const previousEvent = rundownData.entries[applicationOptions.lastEventId];
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) { if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd; newEntry.timeStart = previousEvent.timeEnd;
} }
} }
// Override event with options from editor settings // Override event with options from editor settings
if (applicationOptions.defaultPublic) { newEntry.linkStart = applicationOptions.linkPrevious;
newEvent.isPublic = true; newEntry.isPublic = applicationOptions.defaultPublic;
if (newEntry.duration === undefined && newEntry.timeEnd === undefined) {
newEntry.duration = parseUserTime(defaultDuration);
} }
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) { if (newEntry.timeDanger === undefined) {
newEvent.duration = parseUserTime(defaultDuration); newEntry.timeDanger = parseUserTime(defaultDangerTime);
} }
if (newEvent.timeDanger === undefined) { if (newEntry.timeWarning === undefined) {
newEvent.timeDanger = parseUserTime(defaultDangerTime); newEntry.timeWarning = parseUserTime(defaultWarnTime);
} }
if (newEvent.timeWarning === undefined) { if (newEntry.timerType === undefined) {
newEvent.timeWarning = parseUserTime(defaultWarnTime); newEntry.timerType = defaultTimerType;
} }
if (newEvent.timerType === undefined) { if (newEntry.endAction === undefined) {
newEvent.timerType = defaultTimerType; newEntry.endAction = defaultEndAction;
} }
if (newEvent.endAction === undefined) { if (newEntry.timeStrategy === undefined) {
newEvent.endAction = defaultEndAction; newEntry.timeStrategy = defaultTimeStrategy;
}
if (newEvent.timeStrategy === undefined) {
newEvent.timeStrategy = defaultTimeStrategy;
} }
} }
// handle adding options that concern all event type // handle adding options that concern all event type
if (options?.after) { if (options?.after) {
// @ts-expect-error -- not sure how to type this, <after> is a transient property (newEntry as TransientEventPayload).after = options.after;
newEvent.after = options.after;
} }
if (options?.before) { if (options?.before) {
// @ts-expect-error -- not sure how to type this, <before> is a transient property (newEntry as TransientEventPayload).before = options.before;
newEvent.before = options.before;
} }
try { try {
await _addEventMutation.mutateAsync(newEvent as TransientEventPayload); await _addEntryMutation.mutateAsync(newEntry);
} catch (error) { } catch (error) {
logAxiosError('Failed adding event', error); logAxiosError('Failed adding event', error);
} }
}, },
[ [
_addEventMutation, _addEntryMutation,
defaultDangerTime, defaultDangerTime,
defaultDuration, defaultDuration,
defaultEndAction, defaultEndAction,
@@ -169,26 +167,56 @@ export const useEventAction = () => {
); );
/** /**
* Calls mutation to update existing event * Calls mutation to clone a selection
* @private * @private
*/ */
const _updateEventMutation = useMutation({ const _cloneMutation = useMutation({
mutationFn: requestPutEvent, 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 // we optimistically update here
onMutate: async (newEvent) => { onMutate: async (newEvent) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
const eventId = newEvent.id; const eventId = newEvent.id;
if (previousData && eventId) { if (previousData && eventId) {
// optimistically update object // optimistically update object
const newRundown = { ...previousData.rundown }; const newRundown = { ...previousData.entries };
// @ts-expect-error -- we expect the events to be of same type // @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent }; newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); 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 a context with the previous and new events
@@ -196,46 +224,45 @@ export const useEventAction = () => {
}, },
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: async () => { onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN }); await queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Updates existing event * Updates existing entry
*/ */
const updateEvent = useCallback( const updateEntry = useCallback(
async (event: Partial<OntimeRundownEntry>) => { async (event: Partial<OntimeEntry>) => {
try { try {
await _updateEventMutation.mutateAsync(event); await _updateEntryMutation.mutateAsync(event);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
}, },
[_updateEventMutation], [_updateEntryMutation],
); );
const updateCustomField = useCallback( const updateCustomField = useCallback(
async (eventId: string, field: string, value: string) => { async (entryId: EntryId, field: string, value: string) => {
updateEvent({ id: eventId, custom: { [field]: value } }); updateEntry({ id: entryId, custom: { [field]: value } });
}, },
[updateEvent], [updateEntry],
); );
/** /**
* Updates time of existing event * Updates time of existing event
* @param eventId {string} - id of the event * @param eventId {EntryId} - id of the event
* @param field {TimeField} - field to update * @param field {TimeField} - field to update
* @param value {string} - new value string to be parsed * @param value {string} - new value string to be parsed
* @param lockOnUpdate {boolean} - whether we will apply the lock / release on update * @param lockOnUpdate {boolean} - whether we will apply the lock / release on update
*/ */
const updateTimer = useCallback( const updateTimer = useCallback(
async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => { async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => {
// an empty value with no lock has no domain validity // an empty value with no lock has no domain validity
if (!lockOnUpdate && value === '') { if (!lockOnUpdate && value === '') {
return; return;
@@ -257,7 +284,7 @@ export const useEventAction = () => {
newEvent.duration = value === '' ? undefined : calculateNewValue(); newEvent.duration = value === '' ? undefined : calculateNewValue();
} else if (field === 'timeStart') { } else if (field === 'timeStart') {
// an empty values means we should link to the previous // an empty values means we should link to the previous
newEvent.linkStart = value === '' ? 'true' : null; newEvent.linkStart = value === '';
newEvent.timeStart = value === '' ? undefined : calculateNewValue(); newEvent.timeStart = value === '' ? undefined : calculateNewValue();
} }
} else { } else {
@@ -265,7 +292,7 @@ export const useEventAction = () => {
} }
try { try {
await _updateEventMutation.mutateAsync(newEvent); await _updateEntryMutation.mutateAsync(newEvent);
} catch (error) { } catch (error) {
logAxiosError('Error updating event', error); logAxiosError('Error updating event', error);
} }
@@ -296,9 +323,9 @@ export const useEventAction = () => {
* Utility function to get the previous event end time * Utility function to get the previous event end time
*/ */
function getPreviousEnd(): number { function getPreviousEnd(): number {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN); const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) { if (!cachedRundown?.order || !cachedRundown?.entries) {
return 0; return 0;
} }
@@ -308,7 +335,7 @@ export const useEventAction = () => {
} }
let previousEnd = 0; let previousEnd = 0;
for (let i = index - 1; i >= 0; i--) { for (let i = index - 1; i >= 0; i--) {
const event = cachedRundown.rundown[cachedRundown.order[i]]; const event = cachedRundown.entries[cachedRundown.order[i]];
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
previousEnd = event.timeEnd; previousEnd = event.timeEnd;
break; break;
@@ -317,7 +344,7 @@ export const useEventAction = () => {
return previousEnd; return previousEnd;
} }
}, },
[_updateEventMutation, queryClient], [_updateEntryMutation, queryClient],
); );
/** /**
@@ -325,17 +352,17 @@ export const useEventAction = () => {
* @private * @private
*/ */
const _batchUpdateEventsMutation = useMutation({ const _batchUpdateEventsMutation = useMutation({
mutationFn: requestBatchPutEvents, mutationFn: putBatchEditEvents,
onMutate: async ({ ids, data }) => { onMutate: async ({ ids, data }) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousEvents) { if (previousRundown) {
const eventIds = new Set(ids); const eventIds = new Set(ids);
const newRundown = { ...previousEvents.rundown }; const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => { eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, eventId)) { if (Object.hasOwn(newRundown, eventId)) {
@@ -349,18 +376,25 @@ export const useEventAction = () => {
} }
}); });
queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 }); 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 and new events
return { previousEvents }; // Return a context with the previous rundown
return { previousRundown };
}, },
onSettled: async () => { onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN }); await queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousEvents); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
}, },
networkMode: 'always',
}); });
const batchUpdateEvents = useCallback( const batchUpdateEvents = useCallback(
@@ -375,30 +409,29 @@ export const useEventAction = () => {
); );
/** /**
* Calls mutation to delete an event * Calls mutation to delete an entry
* @private * @private
*/ */
const _deleteEventMutation = useMutation({ const _deleteEntryMutation = useMutation({
mutationFn: requestDelete, mutationFn: deleteEntries,
// we optimistically update here // we optimistically update here
onMutate: async (eventIds: string[]) => { onMutate: async (entryIds: EntryId[]) => {
// cancel ongoing queries // cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const newOrder = previousData.order.filter((id) => !eventIds.includes(id)); const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData);
const newRundown = { ...previousData.rundown };
for (const eventId of eventIds) {
delete newRundown[eventId];
}
queryClient.setQueryData(RUNDOWN, { queryClient.setQueryData<Rundown>(RUNDOWN, {
order: newOrder, id: previousData.id,
rundown: newRundown, title: previousData.title,
order,
flatOrder,
entries,
revision: -1, revision: -1,
}); });
} }
@@ -408,36 +441,35 @@ export const useEventAction = () => {
}, },
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => { onError: (_error, _entryIds, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Deletes an event form the list * Deletes an event entry from the rundown
*/ */
const deleteEvent = useCallback( const deleteEntry = useCallback(
async (eventIds: string[]) => { async (entryIds: EntryId[]) => {
try { try {
await _deleteEventMutation.mutateAsync(eventIds); await _deleteEntryMutation.mutateAsync(entryIds);
} catch (error) { } catch (error) {
logAxiosError('Error deleting event', error); logAxiosError('Error deleting event', error);
} }
}, },
[_deleteEventMutation], [_deleteEntryMutation],
); );
/** /**
* Calls mutation to delete all events * Calls mutation to delete all events
* @private * @private
*/ */
const _deleteAllEventsMutation = useMutation({ const _deleteAllEntriesMutation = useMutation({
mutationFn: requestDeleteAll, mutationFn: requestDeleteAll,
// we optimistically update here // we optimistically update here
onMutate: async () => { onMutate: async () => {
@@ -445,37 +477,43 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
// optimistically update object // optimistically update object
queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 }); 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 a context with the previous and new events
return { previousData }; return { previousData };
}, },
// Mutation fails, rollback undos optimist update // Mutation fails, rollback optimist update
onError: (_error, _eventId, context) => { onError: (_error, _, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Deletes all events from list * Deletes all entries in the rundown
*/ */
const deleteAllEvents = useCallback(async () => { const deleteAllEntries = useCallback(async () => {
try { try {
await _deleteAllEventsMutation.mutateAsync(); await _deleteAllEntriesMutation.mutateAsync();
} catch (error) { } catch (error) {
logAxiosError('Error deleting events', error); logAxiosError('Error deleting events', error);
} }
}, [_deleteAllEventsMutation]); }, [_deleteAllEntriesMutation]);
/** /**
* Calls mutation to apply a delay * Calls mutation to apply a delay
@@ -487,14 +525,13 @@ export const useEventAction = () => {
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Applies a given delay block * Applies a given delay
*/ */
const applyDelay = useCallback( const applyDelay = useCallback(
async (delayEventId: string) => { async (delayEventId: EntryId) => {
try { try {
await _applyDelayMutation.mutateAsync(delayEventId); await _applyDelayMutation.mutateAsync(delayEventId);
} catch (error) { } catch (error) {
@@ -505,61 +542,133 @@ export const useEventAction = () => {
); );
/** /**
* Calls mutation to reorder an event * Calls mutation to dissolve a block
* @private * @private
*/ */
const _reorderEventMutation = useMutation({ const _ungroupMutation = useMutation({
mutationFn: requestReorderEvent, mutationFn: requestUngroup,
// we optimistically update here onSuccess: (response) => {
onMutate: async (data) => { if (!response.data) return;
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value const { id, title, order, flatOrder, entries, revision } = response.data;
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
if (previousData) { /**
// optimistically update object * Deletes a block and moves its events to the top level
const newOrder = reorderArray(previousData.order, data.from, data.to); */
const ungroup = useCallback(
queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 }); async (blockId: EntryId) => {
try {
await _ungroupMutation.mutateAsync(blockId);
} catch (error) {
logAxiosError('Error dissolving block', error);
} }
// Return a context with the previous and new events
return { previousData };
}, },
[_ungroupMutation],
);
// Mutation fails, rollback undoes optimist update /**
onError: (_error, _eventId, context) => { * Calls mutation to create a block with a selection
queryClient.setQueryData(RUNDOWN, context?.previousData); * @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 // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
* Reorders a given event * Reorders a given entry
*/ */
const reorderEvent = useCallback( const reorderEntry = useCallback(
async (eventId: string, from: number, to: number) => { async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
try { try {
const reorderObject: ReorderEntry = { const reorderObject: ReorderEntry = {
eventId, entryId,
from, destinationId,
to, order,
}; };
await _reorderEventMutation.mutateAsync(reorderObject); await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) { } catch (error) {
logAxiosError('Error re-ordering event', error); logAxiosError('Error re-ordering event', error);
} }
}, },
[_reorderEventMutation], [_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 * Calls mutation to swap events
* @private * @private
@@ -572,22 +681,29 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const newRundown = { ...previousData.rundown }; const newRundown = { ...previousData.entries };
const eventA = previousData.rundown[from]; const eventA = previousData.entries[from];
const eventB = previousData.rundown[to]; const eventB = previousData.entries[to];
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return; return;
} }
const { newA, newB } = swapEventData(eventA, eventB); const [newA, newB] = swapEventData(eventA, eventB);
newRundown[from] = newA; newRundown[from] = newA;
newRundown[to] = newB; newRundown[to] = newB;
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); 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 a context with the previous events
@@ -596,14 +712,13 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => { onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
networkMode: 'always',
}); });
/** /**
@@ -621,16 +736,48 @@ export const useEventAction = () => {
); );
return { return {
addEvent, addEntry,
applyDelay, applyDelay,
batchUpdateEvents, batchUpdateEvents,
deleteEvent, clone,
deleteAllEvents, deleteEntry,
getEventById, deleteAllEntries,
reorderEvent, ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents, swapEvents,
updateEvent, updateEntry,
updateTimer, updateTimer,
updateCustomField, 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 };
}
@@ -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) {
-10
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,
+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,5 +8,4 @@ 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,14 +1,12 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { baseURI } from '../../externals';
export enum AppMode { export enum AppMode {
Run = 'run', Run = 'run',
Edit = 'edit', Edit = 'edit',
Freeze = 'freeze', Freeze = 'freeze',
} }
const appModeKey = `${baseURI}ontime-app-mode`; const appModeKey = 'ontime-app-mode';
function getModeFromSession() { function getModeFromSession() {
return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit; return sessionStorage.getItem(appModeKey) === AppMode.Run ? AppMode.Run : AppMode.Edit;
+1 -3
View File
@@ -1,8 +1,6 @@
import { ClientList } from 'ontime-types'; import { ClientList } from 'ontime-types';
import { create } from 'zustand'; import { create } from 'zustand';
import { baseURI } from '../../externals';
interface ClientStore { interface ClientStore {
name?: string; name?: string;
setName: (newValue: string) => void; setName: (newValue: string) => void;
@@ -17,7 +15,7 @@ interface ClientStore {
setClients: (clients: ClientList) => void; setClients: (clients: ClientList) => void;
} }
const clientNameKey = `${baseURI}ontime-client-name`; const clientNameKey = 'ontime-client-name';
function persistNameInStorage(newValue: string) { function persistNameInStorage(newValue: string) {
localStorage.setItem(clientNameKey, newValue); localStorage.setItem(clientNameKey, newValue);
+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
View File
@@ -1,8 +1,6 @@
// eslint-disable-next-line simple-import-sort/imports
import { create } from 'zustand'; import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage'; import { booleanFromLocalStorage } from '../utils/localStorage';
import { baseURI } from '../../externals';
enum LocalEventKeys { enum LocalEventKeys {
Mirror = 'ontime-view-mirror', Mirror = 'ontime-view-mirror',
@@ -14,11 +12,11 @@ type ViewOptionsStore = {
}; };
export const useViewOptionsStore = create<ViewOptionsStore>()((set) => ({ export const useViewOptionsStore = create<ViewOptionsStore>()((set) => ({
mirror: booleanFromLocalStorage(`${baseURI}${LocalEventKeys.Mirror}`, false), mirror: booleanFromLocalStorage(LocalEventKeys.Mirror, false),
toggleMirror: (newValue?: boolean) => toggleMirror: (newValue?: boolean) =>
set((state) => { set((state) => {
const val = typeof newValue === 'undefined' ? !state.mirror : newValue; const val = typeof newValue === 'undefined' ? !state.mirror : newValue;
localStorage.setItem(`${baseURI}${LocalEventKeys.Mirror}`, String(val)); localStorage.setItem(LocalEventKeys.Mirror, String(val));
return { mirror: val }; return { mirror: val };
}), }),
})); }));
@@ -2,4 +2,4 @@
exports[`cx() > ignores falsy values 1`] = `""`; exports[`cx() > ignores falsy values 1`] = `""`;
exports[`cx() > merges styles 1`] = `"_test_d5d33f _another_d5d33f"`; exports[`cx() > merges styles 1`] = `"_test_98a1e0 _another_98a1e0"`;
@@ -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,
@@ -29,7 +30,7 @@ describe('cloneEvent()', () => {
gap: 0, gap: 0,
custom: { custom: {
lighting: '3', lighting: '3',
} as EventCustomFields, } as EntryCustomFields,
}; };
const cloned = cloneEvent(original); const cloned = cloneEvent(original);
@@ -37,7 +38,7 @@ describe('cloneEvent()', () => {
expect(cloned.custom).not.toBe(original.custom); expect(cloned.custom).not.toBe(original.custom);
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 +46,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,
@@ -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", () => {
expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
})
it("considers edge cases for the url sharing feature", () => {
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
})
}); });
it('checks whether the params match', () => {
expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
});
it('considers edge cases for the url sharing feature', () => {
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
});
});
@@ -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,6 +23,7 @@ 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,
+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;
}
+11 -67
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,7 +48,7 @@ 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);
@@ -60,7 +59,6 @@ export const connectSocket = () => {
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);
+1 -1
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';
} }
-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>;
}
}
@@ -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,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 {
@@ -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,6 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { 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';
@@ -24,7 +23,6 @@ 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) {
@@ -36,7 +34,6 @@ 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>({
@@ -47,11 +44,6 @@ 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');
@@ -67,7 +59,6 @@ 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) {
@@ -75,10 +66,6 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
} }
}; };
const handleAddCustom = () => {
append({ title: '', value: '' });
};
return ( return (
<Panel.Section <Panel.Section
as='form' as='form'
@@ -164,42 +151,6 @@ 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 { useFieldArray, useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { IoAdd, IoDownloadOutline, IoTrash } from 'react-icons/io5'; import { 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,7 +25,6 @@ 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,
@@ -33,12 +32,6 @@ 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
@@ -84,10 +77,6 @@ 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);
@@ -242,69 +231,6 @@ 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,18 +57,3 @@
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();
}; };
@@ -1,11 +1,9 @@
import { ImportCustom, ImportMap } from 'ontime-utils'; import { ImportCustom, ImportMap } from 'ontime-utils';
import { baseURI } from '../../../../../externals';
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',
@@ -66,11 +64,11 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
} }
export function persistImportMap(options: NamedImportMap) { export function persistImportMap(options: NamedImportMap) {
localStorage.setItem(`${baseURI}ontime-import-options`, JSON.stringify(options)); localStorage.setItem('ontime-import-options', JSON.stringify(options));
} }
function getPersistImportMap(): unknown { function getPersistImportMap(): unknown {
const options = localStorage.getItem(`${baseURI}ontime-import-options`); const options = localStorage.getItem('ontime-import-options');
if (!options) { if (!options) {
throw new Error('no import options found'); throw new Error('no import options found');
} }
@@ -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,11 +2,10 @@ 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';
import { baseURI } from '../../../../externals';
import { tooltipDelayMid } from '../../../../ontimeConfig'; import { tooltipDelayMid } from '../../../../ontimeConfig';
import TapButton from '../tap-button/TapButton'; import TapButton from '../tap-button/TapButton';
@@ -18,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: `${baseURI}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);
@@ -45,10 +45,6 @@
@include ellipsis-overflow; @include ellipsis-overflow;
} }
.offset {
color: $muted-gray;
}
.ahead { .ahead {
color: $playback-ahead; color: $playback-ahead;
} }
@@ -1,5 +1,4 @@
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';
@@ -164,10 +163,10 @@ function ProgressOverview() {
} }
function RuntimeOverview() { function RuntimeOverview() {
const { clock, offset, playback } = useRuntimePlaybackOverview(); const { clock, offset } = useRuntimePlaybackOverview();
const offsetText = getOffsetText(offset); const offsetText = getOffsetText(offset);
const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]); const offsetClasses = offset === null ? undefined : 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 {
+271 -129
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) {
const newEvent = {
type: SupportedEvent.Event,
};
if (!above && id) { if (!above && id) {
options.lastEventId = id; options.lastEventId = id;
} }
addEvent(newEvent, options); addEntry(patch, options);
} else {
addEvent({ type }, 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]);
useEffect(() => {
// in run mode, we follow selection // in run mode, we follow selection
useEffect(() => {
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 fromIndex = active.data.current?.sortable.index;
const toIndex = over.data.current?.sortable.index; const toIndex = over.data.current?.sortable.index;
// ugly hack to handle inconsistencies between dnd-kit and async store updates
setStatefulEntries((currentEntries) => { // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
return arrayMove(currentEntries, fromIndex, toIndex); setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
}); });
reorderEvent(String(active.id), 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;
const isLast = index === sortableData.length - 1;
// the entry might be a pseudo block-end which does not generate metadata and should not be processed
if (entryId.startsWith('end-')) {
const parentId = entryId.split('end-')[1];
const isBlockCollapsed = getIsCollapsed(parentId);
if (isBlockCollapsed && isEditMode && isLast) {
return <QuickAddBlock key={entryId} previousEventId={parentId} parentBlock={null} />;
} else if (isBlockCollapsed) {
return null;
} 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>
);
}
}
// we iterate through a stateful copy of order to make the dnd operations smoother
// this means that this can be out of sync with order until the useEffect runs // 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 // instead of writing all the logic guards, we simply short circuit rendering here
const entry = rundown[entryId]; const entry = entries[entryId];
if (!entry) { 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; 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)) {
isNextDay = checkIsNextDay(entry, lastEvent);
if (!isPast) {
totalGap += entry.gap;
// 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 (isNewLatest(entry, lastEvent)) {
// populate previous entry
thisEvent = entry;
}
}
}
const isFirst = index === 0;
const isLast = index === order.length - 1;
const isLoaded = featureData?.selectedEventId === entry.id;
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}
parentBlock={isFirst ? null : rundownMetadata.groupId}
backgroundColor={isFirst ? undefined : blockColour}
/>
)}
{isOntimeBlock(entry) ? (
<BlockBlock
data={entry}
hasCursor={hasCursor}
collapsed={getIsCollapsed(entry.id)}
onCollapse={handleCollapseGroup}
/>
) : (
<div
className={style.entryWrapper}
data-testid={`entry-${rundownMetadata.eventIndex}`}
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}> <div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry <RundownEntry
type={entry.type} type={entry.type}
isPast={isPast} isPast={rundownMetadata.isPast}
eventIndex={eventIndex} eventIndex={rundownMetadata.eventIndex}
data={entry} data={entry}
loaded={isLoaded} loaded={rundownMetadata.isLoaded}
hasCursor={hasCursor} hasCursor={hasCursor}
isNext={isNext} isNext={isNext}
previousEntryId={previousEntryId} previousEntryId={rundownMetadata.previousEntryId}
previousEventId={lastEvent?.id} previousEventId={rundownMetadata.previousEvent?.id}
playback={isLoaded ? featureData.playback : undefined} playback={rundownMetadata.isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll} isRolling={featureData.playback === Playback.Roll}
isNextDay={isNextDay} isNextDay={rundownMetadata.isNextDay}
totalGap={totalGap} totalGap={rundownMetadata.totalGap}
isLinkedToLoaded={isLinkedToLoaded} 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}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='ontime-filled' leftIcon={<IoAdd />}>
Create Event Create Event
</Button> </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;
&.isDragging {
cursor: grabbing;
} }
.actionMenu { &.notAllowed {
justify-self: flex-end; 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])}
ref={setNodeRef}
onContextMenu={onContextMenu}
style={{
...(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 /> <IoReorderTwo />
</span> </span>
</div>
<div className={style.header}>
<div className={style.titleRow}>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' /> <EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<BlockDelete onDelete={onDelete} /> <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>
); );
} }
@@ -1,27 +0,0 @@
import { IoTrash } from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
interface BlockDeleteProps {
onDelete: () => void;
}
export default function BlockDelete(props: BlockDeleteProps) {
const { onDelete } = props;
const mode = useAppMode((state) => state.mode);
const isRunMode = mode === AppMode.Run;
return (
<IconButton
aria-label='Delete'
size='sm'
icon={<IoTrash />}
variant='ontime-subtle'
color='#FA5656'
onClick={onDelete}
isDisabled={isRunMode}
/>
);
}
@@ -0,0 +1,10 @@
@use '../blockMixins' as *;
.blockEnd {
cursor: default;
height: 0.5rem;
background-color: var(--user-bg, $gray-1050);
border-radius: 0 0 $block-border-radius $block-border-radius;
margin-bottom: 0.25rem;
}
@@ -0,0 +1,42 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import style from './BlockEnd.module.scss';
interface BlockEndProps {
id: string;
colour?: string;
}
export default function BlockEnd(props: BlockEndProps) {
const { id, colour } = props;
const {
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef,
transform,
transition,
} = useSortable({
id,
animateLayoutChanges: () => false,
disabled: true, // we do not want to drag end blocks
});
const dragStyle = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
className={style.blockEnd}
ref={setNodeRef}
{...dragAttributes}
{...dragListeners}
style={{
...dragStyle,
...(colour ? { '--user-bg': colour } : {}),
}}
/>
);
}
@@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react';
import { Input } from '@chakra-ui/react'; import { Input } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './TitleEditor.module.scss'; import style from './TitleEditor.module.scss';
@@ -16,7 +16,7 @@ interface TitleEditorProps {
export default function EditableBlockTitle(props: TitleEditorProps) { export default function EditableBlockTitle(props: TitleEditorProps) {
const { title, eventId, placeholder, className } = props; const { title, eventId, placeholder, className } = props;
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback( const submitCallback = useCallback(
(text: string) => { (text: string) => {
@@ -25,9 +25,9 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
} }
const cleanVal = text.trim(); const cleanVal = text.trim();
updateEvent({ id: eventId, title: cleanVal }); updateEntry({ id: eventId, title: cleanVal });
}, },
[title, updateEvent, eventId], [title, updateEntry, eventId],
); );
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, ref, { const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, ref, {
@@ -1,11 +1,11 @@
@use '../blockMixins' as *; @use '../blockMixins' as *;
.delay { .delay {
@include block-spacing;
@include block-styling; @include block-styling;
margin-left: 0.5rem;
margin-block: 0.25rem;
background-color: $block-bg2; background-color: $block-bg2;
padding-right: 0.5rem;
display: grid; display: grid;
grid-template-columns: 2rem 1fr auto; grid-template-columns: 2rem 1fr auto;
@@ -6,7 +6,7 @@ import { CSS } from '@dnd-kit/utilities';
import { OntimeDelay } from 'ontime-types'; import { OntimeDelay } from 'ontime-types';
import DelayInput from '../../../common/components/input/delay-input/DelayInput'; import DelayInput from '../../../common/components/input/delay-input/DelayInput';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './DelayBlock.module.scss'; import style from './DelayBlock.module.scss';
@@ -18,21 +18,26 @@ interface DelayBlockProps {
export default function DelayBlock(props: DelayBlockProps) { export default function DelayBlock(props: DelayBlockProps) {
const { data, hasCursor } = props; const { data, hasCursor } = props;
const { applyDelay, deleteEvent } = useEventAction(); const { applyDelay, deleteEntry } = useEntryActions();
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const { const {
attributes: dragAttributes, attributes: dragAttributes,
listeners: dragListeners, listeners: dragListeners,
setNodeRef, setNodeRef,
isDragging,
transform, transform,
transition, transition,
} = useSortable({ } = useSortable({
id: data.id, id: data.id,
data: {
type: 'delay',
},
animateLayoutChanges: () => false, animateLayoutChanges: () => false,
}); });
const dragStyle = { const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform), transform: CSS.Translate.toString(transform),
transition, transition,
}; };
@@ -48,7 +53,7 @@ export default function DelayBlock(props: DelayBlockProps) {
}; };
const cancelDelayHandler = () => { const cancelDelayHandler = () => {
deleteEvent([data.id]); deleteEntry([data.id]);
}; };
const blockClasses = cx([style.delay, hasCursor ? style.hasCursor : null]); const blockClasses = cx([style.delay, hasCursor ? style.hasCursor : null]);
@@ -5,6 +5,7 @@ $skip-opacity: 0.2;
.eventBlock { .eventBlock {
@include block-styling; @include block-styling;
background-color: $block-bg; background-color: $block-bg;
margin-block: 0.25rem;
display: grid; display: grid;
grid-template-areas: grid-template-areas:
@@ -2,6 +2,7 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { import {
IoAdd, IoAdd,
IoDuplicateOutline, IoDuplicateOutline,
IoFolder,
IoLink, IoLink,
IoPeople, IoPeople,
IoPeopleOutline, IoPeopleOutline,
@@ -12,7 +13,7 @@ import {
} from 'react-icons/io5'; } from 'react-icons/io5';
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 { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, EntryId, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
@@ -26,13 +27,13 @@ import RundownIndicators from './RundownIndicators';
import style from './EventBlock.module.scss'; import style from './EventBlock.module.scss';
interface EventBlockProps { interface EventBlockProps {
eventId: string; eventId: EntryId;
cue: string; cue: string;
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
duration: number; duration: number;
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: boolean;
countToEnd: boolean; countToEnd: boolean;
eventIndex: number; eventIndex: number;
isPublic: boolean; isPublic: boolean;
@@ -45,6 +46,7 @@ interface EventBlockProps {
isPast: boolean; isPast: boolean;
isNext: boolean; isNext: boolean;
skip: boolean; skip: boolean;
parent: EntryId | null;
loaded: boolean; loaded: boolean;
hasCursor: boolean; hasCursor: boolean;
playback?: Playback; playback?: Playback;
@@ -86,6 +88,7 @@ export default function EventBlock(props: EventBlockProps) {
isPast, isPast,
isNext, isNext,
skip = false, skip = false,
parent,
loaded, loaded,
hasCursor, hasCursor,
playback, playback,
@@ -142,6 +145,7 @@ export default function EventBlock(props: EventBlockProps) {
value: false, value: false,
}), }),
}, },
{ withDivider: true, label: 'Group', icon: IoFolder, onClick: () => actionHandler('group') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') }, { withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
] ]
: [ : [
@@ -151,7 +155,7 @@ export default function EventBlock(props: EventBlockProps) {
onClick: () => onClick: () =>
actionHandler('update', { actionHandler('update', {
field: 'linkStart', field: 'linkStart',
value: linkStart ? null : 'true', value: linkStart,
}), }),
}, },
{ {
@@ -193,6 +197,10 @@ export default function EventBlock(props: EventBlockProps) {
transition, transition,
} = useSortable({ } = useSortable({
id: eventId, id: eventId,
data: {
type: 'event',
parent,
},
animateLayoutChanges: () => false, animateLayoutChanges: () => false,
}); });
@@ -8,11 +8,10 @@ import {
IoPlay, IoPlay,
IoPlayForward, IoPlayForward,
IoPlaySkipForward, IoPlaySkipForward,
IoStop,
IoTime, IoTime,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig'; import { tooltipDelayMid } from '../../../ontimeConfig';
@@ -31,7 +30,7 @@ interface EventBlockInnerProps {
timeEnd: number; timeEnd: number;
duration: number; duration: number;
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: boolean;
countToEnd: boolean; countToEnd: boolean;
eventIndex: number; eventIndex: number;
isPublic: boolean; isPublic: boolean;
@@ -177,9 +176,6 @@ function EndActionIcon(props: { action: EndAction; className: string }) {
if (action === EndAction.PlayNext) { if (action === EndAction.PlayNext) {
return <IoPlayForward className={maybeActiveClasses} />; return <IoPlayForward className={maybeActiveClasses} />;
} }
if (action === EndAction.Stop) {
return <IoStop className={maybeActiveClasses} />;
}
return <IoPlay className={className} />; return <IoPlay className={className} />;
} }
@@ -2,7 +2,7 @@ import { memo, MouseEvent } from 'react';
import { IoPause, IoPlay, IoReload, IoRemoveCircle, IoRemoveCircleOutline } from 'react-icons/io5'; import { IoPause, IoPlay, IoReload, IoRemoveCircle, IoRemoveCircleOutline } from 'react-icons/io5';
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn'; import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
import { useEventAction } from '../../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { setEventPlayback } from '../../../../common/hooks/useSocket'; import { setEventPlayback } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig'; import { tooltipDelayMid } from '../../../../ontimeConfig';
@@ -34,11 +34,11 @@ interface EventBlockPlaybackProps {
const EventBlockPlayback = (props: EventBlockPlaybackProps) => { const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
const { eventId, skip, isPlaying, isPaused, loaded, disablePlayback } = props; const { eventId, skip, isPlaying, isPaused, loaded, disablePlayback } = props;
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const toggleSkip = (event: MouseEvent) => { const toggleSkip = (event: MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
updateEvent({ id: eventId, skip: !skip }); updateEntry({ id: eventId, skip: !skip });
}; };
const actionHandler = (event: MouseEvent) => { const actionHandler = (event: MouseEvent) => {
@@ -15,23 +15,22 @@ interface CuesheetEventEditorProps {
export default function CuesheetEventEditor(props: CuesheetEventEditorProps) { export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
const { eventId } = props; const { eventId } = props;
const { data } = useRundown(); const { data } = useRundown();
const { order, rundown } = data;
const [event, setEvent] = useState<OntimeEvent | null>(null); const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => { useEffect(() => {
if (order.length === 0) { if (data.order.length === 0) {
setEvent(null); setEvent(null);
return; return;
} }
const event = rundown[eventId]; const event = data.entries[eventId];
if (event && isOntimeEvent(event)) { if (event && isOntimeEvent(event)) {
setEvent(event); setEvent(event);
} else { } else {
setEvent(null); setEvent(null);
} }
}, [data, eventId, order, rundown]); }, [eventId, data.order, data.entries]);
if (!event) { if (!event) {
return null; return null;
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { CustomFieldLabel, OntimeEvent } from 'ontime-types'; import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import * as Editor from '../../editors/editor-utils/EditorUtils'; import * as Editor from '../../editors/editor-utils/EditorUtils';
@@ -14,8 +14,6 @@ import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss'; import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent;
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel; export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
interface EventEditorProps { interface EventEditorProps {
@@ -25,7 +23,7 @@ interface EventEditorProps {
export default function EventEditor(props: EventEditorProps) { export default function EventEditor(props: EventEditorProps) {
const { event } = props; const { event } = props;
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const isEditor = window.location.pathname.includes('editor'); const isEditor = window.location.pathname.includes('editor');
@@ -33,12 +31,12 @@ export default function EventEditor(props: EventEditorProps) {
(field: EditorUpdateFields, value: string) => { (field: EditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) { if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1]; const fieldLabel = field.split('custom-')[1];
updateEvent({ id: event?.id, custom: { [fieldLabel]: value } }); updateEntry({ id: event?.id, custom: { [fieldLabel]: value } });
} else { } else {
updateEvent({ id: event?.id, [field]: value }); updateEntry({ id: event?.id, [field]: value });
} }
}, },
[event?.id, updateEvent], [event?.id, updateEntry],
); );
if (!event) { if (!event) {

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