Compare commits

..

34 Commits

Author SHA1 Message Date
arc-alex bf3d426649 cleanup: millisToSeconds 2025-05-14 21:58:08 +02:00
arc-alex 55c6e70f4a rename 2025-05-14 21:58:08 +02:00
arc-alex f9f1679e8c tweek update rate 2025-05-14 21:58:08 +02:00
arc-alex fca54ab793 clean unused functions 2025-05-14 21:58:07 +02:00
arc-alex 5a38326fb4 clock "is" a count up timer 2025-05-14 21:58:07 +02:00
arc-alex 108db7c87a apply to Timer view 2025-05-14 21:58:07 +02:00
arc-alex 68080d0bc6 change millisToSeconds function 2025-05-14 21:58:07 +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
274 changed files with 8189 additions and 9905 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",
+9 -9
View File
@@ -1,18 +1,18 @@
{ {
"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",
@@ -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,
+33 -17
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,9 +49,9 @@ 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);
} }
@@ -46,9 +62,9 @@ export type ReorderEntry = {
}; };
/** /**
* 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<OntimeEntry>> {
return axios.patch(`${rundownPath}/reorder`, data); return axios.patch(`${rundownPath}/reorder`, data);
} }
@@ -67,15 +83,15 @@ 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: string): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/applydelay/${eventId}`); return axios.patch(`${rundownPath}/applydelay/${delayId}`);
} }
/** /**
* HTTP request to delete given event * HTTP request to delete entries
*/ */
export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> { export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: eventIds } }); 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,30 @@
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,
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, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { import {
deleteEntries,
patchReorderEntry,
postAddEntry,
putBatchEditEvents,
putEditEntry,
ReorderEntry, ReorderEntry,
requestApplyDelay, requestApplyDelay,
requestBatchPutEvents,
requestDelete,
requestDeleteAll, requestDeleteAll,
requestEventSwap, requestEventSwap,
requestPostEvent,
requestPutEvent,
requestReorderEvent,
SwapEntry, SwapEntry,
} from '../api/rundown'; } from '../api/rundown';
import { logAxiosError } from '../api/utils'; import { logAxiosError } from '../api/utils';
@@ -31,18 +32,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,23 +56,24 @@ 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
mutationFn: postAddEntry,
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
@@ -79,14 +81,14 @@ export const useEventAction = () => {
}); });
/** /**
* 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 +98,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 +165,33 @@ export const useEventAction = () => {
); );
/** /**
* Calls mutation to update existing event * Calls mutation to update existing entry
* @private * @private
*/ */
const _updateEventMutation = useMutation({ const _updateEntryMutation = useMutation({
mutationFn: requestPutEvent, 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,7 +199,7 @@ 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
@@ -207,35 +210,35 @@ export const useEventAction = () => {
}); });
/** /**
* 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 +260,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 +268,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 +299,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 +311,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 +320,7 @@ export const useEventAction = () => {
return previousEnd; return previousEnd;
} }
}, },
[_updateEventMutation, queryClient], [_updateEntryMutation, queryClient],
); );
/** /**
@@ -325,17 +328,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,16 +352,24 @@ 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', networkMode: 'always',
}); });
@@ -375,30 +386,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,8 +418,8 @@ 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
@@ -420,24 +430,24 @@ export const useEventAction = () => {
}); });
/** /**
* 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,18 +455,25 @@ 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
@@ -467,15 +484,15 @@ export const useEventAction = () => {
}); });
/** /**
* 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
@@ -491,7 +508,7 @@ export const useEventAction = () => {
}); });
/** /**
* Applies a given delay block * Applies a given delay
*/ */
const applyDelay = useCallback( const applyDelay = useCallback(
async (delayEventId: string) => { async (delayEventId: string) => {
@@ -505,24 +522,30 @@ export const useEventAction = () => {
); );
/** /**
* Calls mutation to reorder an event * Calls mutation to reorder an entry
* @private * @private
*/ */
const _reorderEventMutation = useMutation({ const _reorderEntryMutation = useMutation({
mutationFn: requestReorderEvent, mutationFn: patchReorderEntry,
// we optimistically update here // we optimistically update here
onMutate: async (data) => { onMutate: async (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 previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const newOrder = reorderArray(previousData.order, data.from, data.to); const newOrder = reorderArray(previousData.order, data.from, data.to);
queryClient.setQueryData<Rundown>(RUNDOWN, {
queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 }); id: previousData.id,
title: previousData.title,
order: newOrder,
flatOrder: previousData.flatOrder,
entries: previousData.entries,
revision: -1,
});
} }
// Return a context with the previous and new events // Return a context with the previous and new events
@@ -530,8 +553,8 @@ export const useEventAction = () => {
}, },
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => { onError: (_error, _data, 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
@@ -542,22 +565,22 @@ export const useEventAction = () => {
}); });
/** /**
* Reorders a given event * Reorders a given entry
*/ */
const reorderEvent = useCallback( const reorderEntry = useCallback(
async (eventId: string, from: number, to: number) => { async (entryId: string, from: number, to: number) => {
try { try {
const reorderObject: ReorderEntry = { const reorderObject: ReorderEntry = {
eventId, eventId: entryId,
from, from,
to, to,
}; };
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],
); );
/** /**
@@ -572,22 +595,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,7 +626,7 @@ 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
@@ -621,16 +651,45 @@ export const useEventAction = () => {
); );
return { return {
addEvent, addEntry,
applyDelay, applyDelay,
batchUpdateEvents, batchUpdateEvents,
deleteEvent, deleteEntry,
deleteAllEvents, deleteAllEntries,
getEventById, getEntryById,
reorderEvent, 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 (isOntimeEvent(entry) && entry.parent) {
const parent = entries[entry.parent] as OntimeBlock;
parent.events = parent.events.filter((event) => event !== entry.id);
parent.numEvents -= 1;
} else {
order = order.filter((id) => id !== 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: [],
}; };
@@ -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,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' }]);
});
});
@@ -1,4 +1,4 @@
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 '../eventsManager';
@@ -6,7 +6,7 @@ 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,
@@ -64,13 +64,13 @@ describe('getRouteFromPreset()', () => {
describe('handle url sharing edge cases', () => { describe('handle url sharing edge cases', () => {
it('finds the correct preset when the url contains extra arguments', () => { it('finds the correct preset when the url contains extra arguments', () => {
const location = resolvePath('/demopage?locked=true&token=123'); const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy() expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy();
}) });
it('appends the feature params to the alias', () => { it('appends the feature params to the alias', () => {
const location = resolvePath('/demopage?locked=true&token=123'); const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123') expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
}) });
}); });
}); });
@@ -83,25 +83,26 @@ describe('generatePathFromPreset()', () => {
}); });
test('appends the feature params to the alias', () => { test('appends the feature params to the alias', () => {
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123'); expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe(
'timer?user=guest&alias=demopage&locked=true&token=123',
);
}); });
}); });
describe('arePathsEquivalent()', () => { describe('arePathsEquivalent()', () => {
it("checks whether the paths match", () => { it('checks whether the paths match', () => {
expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy(); expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy();
expect(arePathsEquivalent('timer', 'timer')).toBeTruthy(); expect(arePathsEquivalent('timer', 'timer')).toBeTruthy();
expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy(); expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy();
}) });
it("checks whether the params match", () => { it('checks whether the params match', () => {
expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy(); expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
}) });
it("considers edge cases for the url sharing feature", () => { it('considers edge cases for the url sharing feature', () => {
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy(); expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
}) });
}); });
+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;
}
@@ -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,
+21 -77
View File
@@ -1,4 +1,4 @@
import { Log, RundownCached, RuntimeStore } from 'ontime-types'; import { Log, Rundown, RuntimeStore } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals'; import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants'; import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
@@ -14,13 +14,12 @@ import {
} from '../stores/clientStore'; } from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore'; import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime'; import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
export let websocket: WebSocket | null = null; let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null; let reconnectTimeout: NodeJS.Timeout | null = null;
const reconnectInterval = 1000; const reconnectInterval = 1000;
export let shouldReconnect = true;
export let hasConnected = false; export let hasConnected = false;
export let reconnectAttempts = 0; export let reconnectAttempts = 0;
@@ -49,18 +48,17 @@ export const connectSocket = () => {
websocket.onclose = () => { websocket.onclose = () => {
console.warn('WebSocket disconnected'); console.warn('WebSocket disconnected');
if (shouldReconnect) { // we decide to allows reconnect
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
if (reconnectAttempts > 2) { if (reconnectAttempts > 2) {
setOnlineStatus(false); setOnlineStatus(false);
} }
console.warn('WebSocket: attempting reconnect'); console.warn('WebSocket: attempting reconnect');
if (websocket && websocket.readyState === WebSocket.CLOSED) { if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1; reconnectAttempts += 1;
connectSocket(); connectSocket();
} }
}, reconnectInterval); }, reconnectInterval);
}
}; };
websocket.onerror = (error) => { websocket.onerror = (error) => {
@@ -143,59 +141,10 @@ export const connectSocket = () => {
updateDevTools(serverPayload); updateDevTools(serverPayload);
break; break;
} }
case 'ontime-clock': { case 'ontime-patch': {
addToBatchUpdates('clock', payload); const patch = payload as Partial<RuntimeStore>;
updateDevTools({ clock: payload }); patchRuntime(patch);
break; updateDevTools(patch);
}
case 'ontime-timer': {
addToBatchUpdates('timer', payload);
updateDevTools({ timer: payload });
break;
}
case 'ontime-onAir': {
addToBatchUpdates('onAir', payload);
updateDevTools({ onAir: payload });
break;
}
case 'ontime-message': {
addToBatchUpdates('message', payload);
updateDevTools({ message: payload });
break;
}
case 'ontime-runtime': {
addToBatchUpdates('runtime', payload);
updateDevTools({ runtime: payload });
break;
}
case 'ontime-eventNow': {
addToBatchUpdates('eventNow', payload);
updateDevTools({ eventNow: payload });
break;
}
case 'ontime-currentBlock': {
addToBatchUpdates('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': {
addToBatchUpdates('publicEventNow', payload);
updateDevTools({ publicEventNow: payload });
break;
}
case 'ontime-eventNext': {
addToBatchUpdates('eventNext', payload);
updateDevTools({ eventNext: payload });
break;
}
case 'ontime-publicEventNext': {
addToBatchUpdates('publicEventNext', payload);
updateDevTools({ publicEventNext: payload });
break;
}
case 'ontime-auxtimer1': {
addToBatchUpdates('auxtimer1', payload);
updateDevTools({ auxtimer1: payload });
break; break;
} }
case 'ontime-refetch': { case 'ontime-refetch': {
@@ -205,7 +154,7 @@ export const connectSocket = () => {
invalidateAllCaches(); invalidateAllCaches();
} else if (target === 'RUNDOWN') { } else if (target === 'RUNDOWN') {
const { revision } = payload; const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1; const currentRevision = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) { if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
@@ -215,8 +164,8 @@ export const connectSocket = () => {
} }
break; break;
} }
case 'ontime-flush': { default: {
flushBatchUpdates(); console.log('unknown WS message', type);
break; break;
} }
} }
@@ -226,11 +175,6 @@ export const connectSocket = () => {
}; };
}; };
export const disconnectSocket = () => {
shouldReconnect = false;
websocket?.close();
};
export const socketSend = (message: any) => { export const socketSend = (message: any) => {
if (websocket && websocket.readyState === WebSocket.OPEN) { if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(message); websocket.send(message);
+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,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,4 @@
.empty {
padding-block: 1.5rem;
text-align: center;
}
@@ -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 {
+251 -133
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/eventsManager';
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 } 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, flatOrder, 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(flatOrder, 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();
@@ -65,34 +83,34 @@ export default function Rundown({ data }: RundownProps) {
const deleteAtCursor = useCallback( const deleteAtCursor = useCallback(
(cursor: string | null) => { (cursor: string | null) => {
if (!cursor) return; if (!cursor) return;
const { entry, index } = getPreviousNormal(rundown, order, cursor); const { entry, index } = getPreviousNormal(entries, order, cursor);
deleteEvent([cursor]); deleteEntry([cursor]);
if (entry && index !== null) { if (entry && index !== null) {
setSelectedEvents({ id: entry.id, selectMode: 'click', index }); setSelectedEvents({ id: entry.id, selectMode: 'click', index });
} }
}, },
[rundown, order, deleteEvent, setSelectedEvents], [entries, order, deleteEntry, setSelectedEvents],
); );
const insertCopyAtId = useCallback( const insertCopyAtId = useCallback(
(atId: string | null, copyId: string | null, above = false) => { (atId: string | null, copyId: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, atId ?? '').entry?.id ?? null : atId; const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId;
if (copyId === null) { if (copyId === null) {
// we cant clone without selection // we cant clone without selection
return; return;
} }
const cloneEntry = rundown[copyId]; const cloneEntry = entries[copyId];
if (cloneEntry?.type === SupportedEvent.Event) { if (cloneEntry?.type === SupportedEntry.Event) {
//if we don't have a cursor add the new event on top //if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry); const newEvent = cloneEvent(cloneEntry);
addEvent(newEvent, { after: adjustedCursor ?? undefined }); addEntry(newEvent, { after: adjustedCursor ?? undefined });
} }
}, },
[addEvent, order, rundown], [addEntry, order, entries],
); );
const insertAtId = useCallback( const insertAtId = useCallback(
(type: SupportedEvent, id: MaybeString, above = false) => { (patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: MaybeString, above = false) => {
const options: EventOptions = const options: EventOptions =
id === null id === null
? {} ? {}
@@ -101,19 +119,12 @@ export default function Rundown({ data }: RundownProps) {
before: above ? id : undefined, before: above ? id : undefined,
}; };
if (type === SupportedEvent.Event) { if (!above && id) {
const newEvent = { options.lastEventId = id;
type: SupportedEvent.Event,
};
if (!above && id) {
options.lastEventId = id;
}
addEvent(newEvent, options);
} else {
addEvent({ type }, options);
} }
addEntry(patch, options);
}, },
[addEvent], [addEntry],
); );
const selectBlock = useCallback( const selectBlock = useCallback(
@@ -124,7 +135,7 @@ export default function Rundown({ data }: RundownProps) {
let newCursor = cursor; let newCursor = cursor;
if (cursor === null) { if (cursor === null) {
// there is no cursor, we select the first or last depending on direction // there is no cursor, we select the first or last depending on direction
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order); const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (isOntimeBlock(selected)) { if (isOntimeBlock(selected)) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
@@ -140,14 +151,14 @@ export default function Rundown({ data }: RundownProps) {
// otherwise we select the next or previous // otherwise we select the next or previous
const selected = const selected =
direction === 'up' direction === 'up'
? getPreviousBlockNormal(rundown, order, newCursor) ? getPreviousBlockNormal(entries, order, newCursor)
: getNextBlockNormal(rundown, order, newCursor); : getNextBlockNormal(entries, order, newCursor);
if (selected.entry !== null && selected.index !== null) { if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
} }
}, },
[order, rundown, setSelectedEvents], [order, entries, setSelectedEvents],
); );
const selectEntry = useCallback( const selectEntry = useCallback(
@@ -158,7 +169,7 @@ export default function Rundown({ data }: RundownProps) {
if (cursor === null) { if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists // there is no cursor, we select the first or last depending on direction if it exists
const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order); const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (selected !== null) { if (selected !== null) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
} }
@@ -167,13 +178,13 @@ 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(
@@ -182,14 +193,14 @@ export default function Rundown({ data }: RundownProps) {
return; return;
} }
const { index } = const { index } =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor); direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
if (index !== null) { if (index !== null) {
const offsetIndex = direction === 'up' ? index + 1 : index - 1; const offsetIndex = direction === 'up' ? index + 1 : index - 1;
reorderEvent(cursor, offsetIndex, index); reorderEntry(cursor, offsetIndex, index);
} }
}, },
[order, reorderEvent, rundown], [order, reorderEntry, entries],
); );
// shortcuts // shortcuts
@@ -207,14 +218,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 +237,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(flatOrder, entries));
}, [order]); }, [flatOrder, entries]);
// in run mode, we follow selection
useEffect(() => { useEffect(() => {
// in run mode, we follow selection
if (appMode !== AppMode.Run || !featureData?.selectedEventId) { if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
return; return;
} }
@@ -238,6 +249,39 @@ 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;
@@ -245,104 +289,178 @@ export default function Rundown({ data }: RundownProps) {
if (active.id !== over?.id) { if (active.id !== over?.id) {
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); reorderEntry(String(active.id), fromIndex, toIndex);
} }
} }
}; };
if (statefulEntries.length < 1) { /**
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />; * 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);
}
};
/**
* 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={() => insertAtId({ type: SupportedEntry.Event }, cursor)} />;
} }
// last event is used to calculate relative timings // 1. gather presentation options
let lastEvent: PlayableEvent | undefined; // used by indicators
let thisEvent: PlayableEvent | undefined;
// previous entry is used to infer position in the rundown for new events
let previousEntryId: string | undefined;
let thisId = previousEntryId;
let eventIndex = 0;
// all events before the current selected are in the past
let isPast = Boolean(featureData?.selectedEventId);
let isNextDay = false;
let totalGap = 0;
const isEditMode = appMode === AppMode.Edit; const isEditMode = appMode === AppMode.Edit;
let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event
// 2. initialise rundown metadata
const { metadata, process } = makeRundownMetadata(featureData?.selectedEventId);
// keep a single reference to the metadata which we override for every entry
let rundownMetadata = metadata;
return ( return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'> <div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}> <DndContext
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}> onDragEnd={handleOnDragEnd}
onDragStart={collapseDraggedBlocks}
onDragOver={expandOverBlock}
sensors={sensors}
collisionDetection={closestCenter}
>
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
<div className={style.list}> <div className={style.list}>
{statefulEntries.map((entryId, index) => { {sortableData.map((entryId, index) => {
// we iterate through a stateful copy of order to make the operations smoother const isFirst = index === 0;
// this means that this can be out of sync with order until the useEffect runs const isLast = index === sortableData.length - 1;
// instead of writing all the logic guards, we simply short circuit rendering here
const entry = rundown[entryId];
if (!entry) {
return null;
}
if (index === 0) {
eventIndex = 0;
}
isNextDay = false;
previousEntryId = thisId;
thisId = entryId;
if (isOntimeEvent(entry)) {
// event indexes are 1 based in frontend
eventIndex++;
lastEvent = thisEvent;
if (isPlayableEvent(entry)) { // the entry might be a pseudo block-end which does not generate metadata and should not be processed
isNextDay = checkIsNextDay(entry, lastEvent); if (entryId.startsWith('end-')) {
if (!isPast) { const parentId = entryId.split('end-')[1];
totalGap += entry.gap; const isBlockCollapsed = getIsCollapsed(parentId);
// We also include countToEnd in this test as the behavior of a linked event coming after a countToEnd is simelar to an unlinked event
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null && !lastEvent?.countToEnd; if (isBlockCollapsed && isEditMode && isLast) {
} return <QuickAddBlock key={entryId} previousEventId={parentId} parentBlock={null} />;
if (isNewLatest(entry, lastEvent)) { } else {
// populate previous entry const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour;
thisEvent = entry; // 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.previousEntryId}
parentBlock={parentId}
backgroundColor={parentColour}
/>
)}
<BlockEnd key={entryId} id={entryId} colour={parentColour} />
{isEditMode && isLast && <QuickAddBlock previousEventId={parentId} parentBlock={null} />}
</Fragment>
);
} }
} }
const isFirst = index === 0;
const isLast = index === order.length - 1; // we iterate through a stateful copy of order to make the dnd operations smoother
const isLoaded = featureData?.selectedEventId === entry.id; // this means that this can be out of sync with order until the useEffect runs
// instead of writing all the logic guards, we simply short circuit rendering here
const entry = entries[entryId];
if (!entry) return null;
rundownMetadata = process(entry);
// if the entry has a parent, and it is collapsed, render nothing
if (
entry.type !== SupportedEntry.Block &&
rundownMetadata.groupId !== null &&
getIsCollapsed(rundownMetadata.groupId)
) {
return null;
}
const isNext = featureData?.nextEventId === entry.id; const isNext = featureData?.nextEventId === entry.id;
const hasCursor = entry.id === cursor; const hasCursor = entry.id === cursor;
if (isLoaded) {
isPast = false; /**
} * Outside a block, the value will be undefined
* If the colour is empty string ''
* ie: we are inside a block, but there is no defined colour
* we default to $gray-1050 #303030
*/
const blockColour = rundownMetadata.groupColour === '' ? '#303030' : rundownMetadata.groupColour;
return ( return (
<Fragment key={entry.id}> <Fragment key={entry.id}>
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEntryId} />} {isEditMode && (hasCursor || isFirst) && (
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}> <QuickAddBlock
{isOntimeEvent(entry) && <div className={style.entryIndex}>{eventIndex}</div>} previousEventId={rundownMetadata.previousEntryId}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}> parentBlock={isFirst ? null : rundownMetadata.groupId}
<RundownEntry backgroundColor={isFirst ? undefined : blockColour}
type={entry.type} />
isPast={isPast} )}
eventIndex={eventIndex} {isOntimeBlock(entry) ? (
data={entry} <BlockBlock
loaded={isLoaded} data={entry}
hasCursor={hasCursor} hasCursor={hasCursor}
isNext={isNext} collapsed={getIsCollapsed(entry.id)}
previousEntryId={previousEntryId} onCollapse={handleCollapseGroup}
previousEventId={lastEvent?.id} />
playback={isLoaded ? featureData.playback : undefined} ) : (
isRolling={featureData.playback === Playback.Roll} <div
isNextDay={isNextDay} className={style.entryWrapper}
totalGap={totalGap} data-testid={`entry-${rundownMetadata.eventIndex}`}
isLinkedToLoaded={isLinkedToLoaded} style={blockColour ? { '--user-bg': blockColour } : {}}
/> >
{isOntimeEvent(entry) && <div className={style.entryIndex}>{rundownMetadata.eventIndex}</div>}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
isPast={rundownMetadata.isPast}
eventIndex={rundownMetadata.eventIndex}
data={entry}
loaded={rundownMetadata.isLoaded}
hasCursor={hasCursor}
isNext={isNext}
previousEntryId={rundownMetadata.previousEntryId}
previousEventId={rundownMetadata.previousEvent?.id}
playback={rundownMetadata.isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
isNextDay={rundownMetadata.isNextDay}
totalGap={rundownMetadata.totalGap}
isLinkedToLoaded={rundownMetadata.isLinkedToLoaded}
/>
</div>
</div> </div>
</div> )}
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={entry.id} />} {isEditMode && (hasCursor || isLast) && (
<QuickAddBlock
previousEventId={entry.id}
parentBlock={rundownMetadata.groupId}
backgroundColor={blockColour}
/>
)}
</Fragment> </Fragment>
); );
})} })}
@@ -3,7 +3,7 @@ import { Button } from '@chakra-ui/react';
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: () => void;
@@ -13,9 +13,9 @@ 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: '7vh', marginBottom: '1.5rem' }} />
<Button onClick={handleAddNew} variant='ontime-filled' className={style.spaceTop} leftIcon={<IoAdd />}> <Button onClick={handleAddNew} variant='ontime-filled' leftIcon={<IoAdd />}>
Create Event Create Event
</Button> </Button>
</div> </div>
@@ -1,12 +1,19 @@
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/eventsManager';
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';
@@ -26,15 +33,15 @@ export type EventItemActions =
| 'clear-report'; | '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 +66,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, swapEvents } = useEntryActions();
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection(); const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
const removeOpenEvent = useCallback(() => { const removeOpenEvent = useCallback(() => {
@@ -79,31 +86,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 +119,14 @@ 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; break;
} }
case 'update': { case 'update': {
@@ -140,7 +147,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 +157,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 +174,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 +192,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 */
@@ -0,0 +1,314 @@
import { OntimeBlock, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types';
import { makeRundownMetadata, makeSortableList } 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', '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,
'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['12'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['12'],
previousEntryId: demoEvents['11'].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 flatOrder = ['block-1', '11', '2', 'block-3', '31', '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(flatOrder, entries);
expect(sortableList).toEqual([
'block-1',
'11',
'end-block-1',
'2',
'block-3',
'31',
'end-block-3',
'block-4',
'end-block-4',
]);
});
it('closes dangling blocks', () => {
const flatOrder = ['block', '11', '12'];
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(flatOrder, entries);
expect(sortableList).toStrictEqual(['block-1', '11', '12', 'end-block-1']);
});
it('handles a list with a with just blocks', () => {
const flatOrder = ['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(flatOrder, entries);
expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']);
});
});
@@ -1,7 +1,7 @@
@use '../../theme/ontimeColours' as *; @use '../../theme/ontimeColours' as *;
@use '../../theme/ontimeStyles' as *; @use '../../theme/ontimeStyles' as *;
$block-width: 33rem; $block-width: 32rem;
$block-gap: 0.25rem; $block-gap: 0.25rem;
$block-element-spacing: 0.25rem; $block-element-spacing: 0.25rem;
@@ -20,28 +20,23 @@ $block-cursor-color: $orange-400;
box-sizing: content-box; box-sizing: content-box;
border: 1px solid $white-7; border: 1px solid $white-7;
border-radius: $block-border-radius; border-radius: $block-border-radius;
margin-block: 0.25rem;
margin-right: 0.125rem;
position: relative; position: relative;
color: $block-text-color; color: $block-text-color;
min-width: $block-width; min-width: $block-width;
} }
@mixin block-spacing() {
padding-right: 0.5rem;
gap: 2px;
}
@mixin drag-style() { @mixin drag-style() {
font-size: 20px; font-size: 20px;
justify-self: center; justify-self: center;
opacity: 0.3; opacity: 0.3;
cursor: grab; cursor: grab;
transition: opacity 0.3s; transition: opacity 0.3s;
&:hover { &:hover {
opacity: 1; opacity: 1;
} }
&:focus { &:focus {
box-shadow: none; box-shadow: none;
outline: none; outline: none;
@@ -1,28 +1,74 @@
@use '../blockMixins' as *; @use '../blockMixins' as *;
.block { .block {
@include block-spacing;
@include block-styling; @include block-styling;
margin-left: 0.5rem; overflow: hidden;
background-color: $block-bg2;
min-width: 34rem;
display: grid; display: grid;
grid-template-columns: 2rem 1fr auto; grid-template-columns: 2rem 1fr;
grid-template-areas: 'binder header';
align-items: center; align-items: center;
height: $secondary-block-height; // TODO(style fix): groups have an extra bottom margin which interrupt colour
gap: 0.5rem; margin-block: 0.25rem;
&.hasCursor { &.hasCursor {
outline: 1px solid $block-cursor-color; outline: 1px solid $block-cursor-color;
} }
&.expanded {
border-radius: $block-border-radius $block-border-radius 0 0;
}
.binder {
grid-area: binder;
height: 100%;
background-color: var(--block-color, $gray-1050);
color: $section-white;
font-size: 1rem;
display: grid;
justify-content: center;
padding-top: 0.25rem;
}
.header {
grid-area: header;
padding-inline: 0.5rem;
background-color: $block-bg2;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.titleRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.metaRow {
display: flex;
gap: 3rem;
margin-bottom: 0.25rem;
}
.metaEntry {
width: 4.5em;
:first-child {
font-size: calc(1rem - 3px);
color: $label-gray;
}
}
} }
.drag { .drag {
@include drag-style; @include drag-style;
}
.actionMenu { &.isDragging {
justify-self: flex-end; cursor: grabbing;
}
&.notAllowed {
cursor: not-allowed;
}
} }
@@ -1,25 +1,25 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { IoReorderTwo } from 'react-icons/io5'; import { IoChevronDown, IoChevronUp, IoReorderTwo } 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 { OntimeBlock } from 'ontime-types'; import { EntryId, OntimeBlock } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils'; 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 { const {
@@ -28,25 +28,72 @@ export default function BlockBlock(props: BlockBlockProps) {
setNodeRef, setNodeRef,
transform, transform,
transition, transition,
isDragging,
isOver,
over,
} = useSortable({ } = useSortable({
id: data.id, id: data.id,
data: {
type: 'block',
},
animateLayoutChanges: () => false, animateLayoutChanges: () => false,
}); });
const binderColours = data.colour && getAccessibleColour(data.colour);
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
const dragStyle = { const dragStyle = {
zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform), transform: CSS.Translate.toString(transform),
transition, transition,
cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'default',
}; };
const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]);
return ( return (
<div className={blockClasses} ref={setNodeRef} style={dragStyle}> <div
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}> className={cx([style.block, hasCursor && style.hasCursor, !collapsed && style.expanded])}
<IoReorderTwo /> ref={setNodeRef}
</span> style={{
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' /> ...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
<BlockDelete onDelete={onDelete} /> ...dragStyle,
}}
>
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
<span
className={cx([style.drag, isDragging && style.isDragging, isDragging && !isValidDrop && style.notAllowed])}
ref={handleRef}
{...dragAttributes}
{...dragListeners}
>
<IoReorderTwo />
</span>
</div>
<div className={style.header}>
<div className={style.titleRow}>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<button onClick={() => onCollapse(!collapsed, data.id)}>
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
</button>
</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.numEvents}</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:
@@ -12,7 +12,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';
@@ -32,7 +32,7 @@ interface EventBlockProps {
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 +45,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 +87,7 @@ export default function EventBlock(props: EventBlockProps) {
isPast, isPast,
isNext, isNext,
skip = false, skip = false,
parent,
loaded, loaded,
hasCursor, hasCursor,
playback, playback,
@@ -151,7 +153,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 +195,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) {
@@ -13,29 +13,28 @@ import style from './EventEditor.module.scss';
export default function RundownEventEditor() { export default function RundownEventEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
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 selectedEventId = order.find((eventId) => selectedEvents.has(eventId)); const selectedEventId = Array.from(selectedEvents).at(0);
if (!selectedEventId) { if (!selectedEventId) {
setEvent(null); setEvent(null);
return; return;
} }
const event = rundown[selectedEventId]; const event = data.entries[selectedEventId];
if (event && isOntimeEvent(event)) { if (event && isOntimeEvent(event)) {
setEvent(event); setEvent(event);
} else { } else {
setEvent(null); setEvent(null);
} }
}, [order, rundown, selectedEvents]); }, [data.order, data.entries, selectedEvents]);
if (!event) { if (!event) {
return <EventEditorEmpty />; return <EventEditorEmpty />;
@@ -1,11 +1,11 @@
import { memo } from 'react'; import { memo } from 'react';
import { IoInformationCircle } from 'react-icons/io5'; import { IoInformationCircle } from 'react-icons/io5';
import { Select, Switch, Tooltip } from '@chakra-ui/react'; import { Select, Switch, Tooltip } from '@chakra-ui/react';
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig'; import { millisToDelayString } from '../../../../common/utils/dateConfig';
import * as Editor from '../../../editors/editor-utils/EditorUtils'; import * as Editor from '../../../editors/editor-utils/EditorUtils';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
@@ -18,7 +18,7 @@ interface EventEditorTimesProps {
timeEnd: number; timeEnd: number;
duration: number; duration: number;
timeStrategy: TimeStrategy; timeStrategy: TimeStrategy;
linkStart: MaybeString; linkStart: boolean;
countToEnd: boolean; countToEnd: boolean;
delay: number; delay: number;
isPublic: boolean; isPublic: boolean;
@@ -46,27 +46,27 @@ function EventEditorTimes(props: EventEditorTimesProps) {
timeWarning, timeWarning,
timeDanger, timeDanger,
} = props; } = props;
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const handleSubmit = (field: HandledActions, value: string | boolean) => { const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'isPublic') { if (field === 'isPublic') {
updateEvent({ id: eventId, isPublic: !(value as boolean) }); updateEntry({ id: eventId, isPublic: !(value as boolean) });
return; return;
} }
if (field === 'countToEnd') { if (field === 'countToEnd') {
updateEvent({ id: eventId, countToEnd: !(value as boolean) }); updateEntry({ id: eventId, countToEnd: !(value as boolean) });
return; return;
} }
if (field === 'timeWarning' || field === 'timeDanger') { if (field === 'timeWarning' || field === 'timeDanger') {
const newTime = parseUserTime(value as string); const newTime = parseUserTime(value as string);
updateEvent({ id: eventId, [field]: newTime }); updateEntry({ id: eventId, [field]: newTime });
return; return;
} }
if (field === 'timerType' || field === 'endAction') { if (field === 'timerType' || field === 'endAction') {
updateEvent({ id: eventId, [field]: value }); updateEntry({ id: eventId, [field]: value });
return; return;
} }
}; };
@@ -114,7 +114,6 @@ function EventEditorTimes(props: EventEditorTimesProps) {
variant='ontime' variant='ontime'
> >
<option value={EndAction.None}>None</option> <option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop rundown</option>
<option value={EndAction.LoadNext}>Load next event</option> <option value={EndAction.LoadNext}>Load next event</option>
<option value={EndAction.PlayNext}>Play next event</option> <option value={EndAction.PlayNext}>Play next event</option>
</Select> </Select>
@@ -5,7 +5,7 @@ import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
import { generateId } from 'ontime-utils'; import { generateId } from 'ontime-utils';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { useEventAction } from '../../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { eventTriggerOptions } from './eventTrigger.constants'; import { eventTriggerOptions } from './eventTrigger.constants';
@@ -37,7 +37,7 @@ interface EventTriggerFormProps {
function EventTriggerForm(props: EventTriggerFormProps) { function EventTriggerForm(props: EventTriggerFormProps) {
const { eventId, triggers } = props; const { eventId, triggers } = props;
const { data: automationSettings } = useAutomationSettings(); const { data: automationSettings } = useAutomationSettings();
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const [automationId, setAutomationId] = useState<string | undefined>(undefined); const [automationId, setAutomationId] = useState<string | undefined>(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart); const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
@@ -45,7 +45,7 @@ function EventTriggerForm(props: EventTriggerFormProps) {
const newTriggers = triggers ?? new Array<Trigger>(); const newTriggers = triggers ?? new Array<Trigger>();
const id = generateId(); const id = generateId();
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId }); newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
updateEvent({ id: eventId, triggers: newTriggers }); updateEntry({ id: eventId, triggers: newTriggers });
}; };
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => { const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
@@ -123,15 +123,15 @@ interface ExistingEventTriggersProps {
function ExistingEventTriggers(props: ExistingEventTriggersProps) { function ExistingEventTriggers(props: ExistingEventTriggersProps) {
const { eventId, triggers } = props; const { eventId, triggers } = props;
const { updateEvent } = useEventAction(); const { updateEntry } = useEntryActions();
const { data: automationSettings } = useAutomationSettings(); const { data: automationSettings } = useAutomationSettings();
const handleDelete = useCallback( const handleDelete = useCallback(
(triggerId: string) => { (triggerId: string) => {
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId); const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
updateEvent({ id: eventId, triggers: newTriggers }); updateEntry({ id: eventId, triggers: newTriggers });
}, },
[eventId, triggers, updateEvent], [eventId, triggers, updateEntry],
); );
const filteredTriggers: Record<string, Trigger[]> = {}; const filteredTriggers: Record<string, Trigger[]> = {};
@@ -3,9 +3,10 @@
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
margin: 0.25rem 0; padding-block: 0.5rem;
font-size: calc(1rem - 3px); font-size: calc(1rem - 3px);
padding-inline: calc(2em + 0.5rem) 0.75rem; padding-left: calc(2em + 0.5rem);
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
} }
.quickBtn { .quickBtn {
@@ -1,73 +1,76 @@
import { memo, useCallback, useRef } from 'react'; import { memo, useRef } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { SupportedEvent } from 'ontime-types'; import { MaybeString, SupportedEntry } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useEmitLog } from '../../../common/stores/logger';
import style from './QuickAddBlock.module.scss'; import style from './QuickAddBlock.module.scss';
interface QuickAddBlockProps { interface QuickAddBlockProps {
previousEventId?: string; previousEventId: MaybeString;
parentBlock: MaybeString;
backgroundColor?: string;
} }
export default memo(QuickAddBlock); export default memo(QuickAddBlock);
function QuickAddBlock(props: QuickAddBlockProps) { function QuickAddBlock(props: QuickAddBlockProps) {
const { previousEventId } = props; const { previousEventId, parentBlock, backgroundColor } = props;
const { addEvent } = useEventAction(); const { addEntry } = useEntryActions();
const { emitError } = useEmitLog();
const doLinkPrevious = useRef<HTMLInputElement | null>(null); const doLinkPrevious = useRef<HTMLInputElement | null>(null);
const doPublic = useRef<HTMLInputElement | null>(null); const doPublic = useRef<HTMLInputElement | null>(null);
const handleCreateEvent = useCallback( const addEvent = () => {
(eventType: SupportedEvent) => { addEntry(
switch (eventType) { {
case 'event': { type: SupportedEntry.Event,
const defaultPublic = doPublic?.current?.checked; parent: parentBlock,
const linkPrevious = doLinkPrevious?.current?.checked; },
{
after: previousEventId,
defaultPublic: doPublic?.current?.checked,
lastEventId: previousEventId,
linkPrevious: doLinkPrevious?.current?.checked,
},
);
};
const newEvent = { type: SupportedEvent.Event }; const addDelay = () => {
const options = { addEntry(
after: previousEventId, { type: SupportedEntry.Delay, parent: parentBlock },
defaultPublic, {
lastEventId: previousEventId, lastEventId: previousEventId,
linkPrevious, after: previousEventId,
}; },
addEvent(newEvent, options); );
break; };
}
case 'delay': { const addBlock = () => {
const options = { if (parentBlock !== null) {
lastEventId: previousEventId, return;
after: previousEventId, }
}; addEntry(
addEvent({ type: SupportedEvent.Delay }, options); { type: SupportedEntry.Block },
break; {
} lastEventId: previousEventId,
case 'block': { after: previousEventId,
const options = { },
lastEventId: previousEventId, );
after: previousEventId, };
};
addEvent({ type: SupportedEvent.Block }, options); /**
break; * If the colour is empty string ''
} * ie: we are inside a block, but there is no defined colour
default: { * we default to $gray-1050 #303030
emitError(`Cannot create unknown event type: ${eventType}`); */
break; const blockColour = backgroundColor === '' ? '#303030' : backgroundColor;
}
}
},
[previousEventId, addEvent, emitError],
);
return ( return (
<div className={style.quickAdd}> <div className={style.quickAdd} style={blockColour ? { '--user-bg': blockColour } : {}}>
<Button <Button
onClick={() => handleCreateEvent(SupportedEvent.Event)} onClick={addEvent}
size='xs' size='xs'
variant='ontime-subtle-white' variant='ontime-subtle-white'
className={style.quickBtn} className={style.quickBtn}
@@ -77,7 +80,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
Event Event
</Button> </Button>
<Button <Button
onClick={() => handleCreateEvent(SupportedEvent.Delay)} onClick={addDelay}
size='xs' size='xs'
variant='ontime-subtle-white' variant='ontime-subtle-white'
className={style.quickBtn} className={style.quickBtn}
@@ -86,16 +89,18 @@ function QuickAddBlock(props: QuickAddBlockProps) {
> >
Delay Delay
</Button> </Button>
<Button {parentBlock === null && (
onClick={() => handleCreateEvent(SupportedEvent.Block)} <Button
size='xs' onClick={addBlock}
variant='ontime-subtle-white' size='xs'
className={style.quickBtn} variant='ontime-subtle-white'
leftIcon={<IoAdd />} className={style.quickBtn}
color='#b1b1b1' // $gray-400 leftIcon={<IoAdd />}
> color='#b1b1b1' // $gray-400
Block >
</Button> Block
</Button>
)}
</div> </div>
); );
} }

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